@taodigital/crawler 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-login.js.map +1 -1
- package/dist/crawler.js.map +2 -2
- package/dist/extractors/summary-extractor.js.map +2 -2
- package/dist/failure-analysis-agent.js.map +1 -1
- package/dist/http-engine.js.map +2 -2
- package/dist/interactive-probe.js.map +1 -1
- package/dist/job-queue.js +60 -18
- package/dist/job-queue.js.map +2 -2
- package/dist/native-crawler.js.map +1 -1
- package/dist/sitemap.js +1 -1
- package/dist/sitemap.js.map +2 -2
- package/dist/test-executor.js.map +2 -2
- package/dist/visual-diff-verdict.js.map +1 -1
- package/dist/worker.js.map +1 -1
- package/package.json +1 -1
- package/src/http-engine.ts +12 -7
- package/src/job-queue.ts +104 -32
- package/src/sitemap.ts +1 -1
- package/types/job-queue.d.ts +5 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/native-crawler.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * ZeTa Native App Crawler \u2014 Appium-based crawler for Android APK and iOS IPA.\n *\n * Architecture:\n * Appium Server (running at APPIUM_SERVER_URL) \u2192 WebDriverIO session\n * \u2192 UiAutomator2 (Android) / XCUITest (iOS)\n * \u2192 accessibility tree XML \u2192 ZeTa Element records\n *\n * TODO: requires Appium server running at APPIUM_SERVER_URL env var\n * TODO: requires `webdriverio` package \u2014 add \"webdriverio\": \"^9.x\" to package.json\n *\n * Device farm support:\n * - local_emulator: Appium server + local emulator/simulator\n * - aws_device_farm: presigned S3 URL uploaded to Device Farm, session via WDIO\n * - browserstack: BrowserStack Automate Appium endpoint\n */\n\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\nimport os from 'node:os';\nimport { withTenant } from '@detiq/database';\nimport { AppBrain, getTenantStorageConfig, uploadScreenshotToTenantStorage, type TenantStorageConfig } from '@detiq/app-brain';\nimport { config } from '@detiq/core';\nimport { logger } from './logger.js';\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\nexport interface NativeCrawlerOptions {\n tenantId: string;\n projectId: string;\n jobId: string;\n platform: 'android' | 'ios' | 'windows' | 'macos';\n appS3Path: string; // s3://bucket/path/app.apk or s3://bucket/path/app.ipa\n packageName?: string; // com.example.app (Android) or bundle ID (iOS)\n deviceModel?: string; // optional device preference\n osVersion?: string; // Appium platformVersion (e.g. \"14.0\" for Android, \"17.0\" for iOS)\n appActivity?: string; // Android: activity to launch (e.g. \".MainActivity\")\n appWaitActivity?: string; // Android: activity to wait for after launch\n deviceFarm: 'local_emulator' | 'aws_device_farm' | 'browserstack';\n // Device farm connection credentials (from workspace settings, overrides env vars)\n appiumServerUrl?: string;\n browserstackUsername?: string;\n browserstackAccessKey?: string;\n awsAccessKeyId?: string;\n awsSecretAccessKey?: string;\n awsRegion?: string;\n awsDeviceFarmProjectArn?: string;\n // Auth credentials for Gap 1: native app login\n credentials?: {\n username?: string;\n password?: string;\n usernameFieldLabel?: string; // hint if default detection fails, e.g. \"Email\"\n passwordFieldLabel?: string;\n loginButtonLabel?: string;\n };\n // Vision fallback AI config for Gap 2: when accessibility tree is empty\n visionLlmConfig?: { provider: string; model: string; apiKey?: string; baseUrl?: string } | null;\n // Gesture config for Gap 3\n gestureConfig?: {\n enableSwipe?: boolean; // default true\n enableLongPress?: boolean; // default true\n };\n // MITM proxy for Gap 4 (when available)\n mitmProxyPort?: number;\n}\n\n/** A native UI element extracted from the accessibility tree. */\ninterface NativeElement {\n role: string;\n label: string;\n bounds: NativeBounds;\n platform: 'android' | 'ios' | 'windows' | 'macos';\n resourceId?: string; // Android: resource-id attribute\n className?: string; // Android: widget class / iOS: XCUIElement type\n}\n\ninterface NativeBounds {\n x: number;\n y: number;\n width: number;\n height: number;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Build Appium desired capabilities for the given platform.\n * localAppPath is the local path after downloading from S3 (or a presigned URL\n * for device farms that fetch the binary themselves).\n */\nfunction buildCapabilities(opts: NativeCrawlerOptions, localAppPath: string): Record<string, unknown> {\n const appiumServerUrl = opts.appiumServerUrl ?? process.env.APPIUM_SERVER_URL ?? 'http://127.0.0.1:4723';\n logger.info({ appiumServerUrl, platform: opts.platform }, 'Building Appium capabilities');\n\n // BrowserStack early-return: https://hub-cloud.browserstack.com/wd/hub\n if (opts.deviceFarm === 'browserstack' && (opts.platform === 'android' || opts.platform === 'ios')) {\n const bstackOptions = {\n userName: opts.browserstackUsername ?? process.env.BROWSERSTACK_USERNAME ?? '',\n accessKey: opts.browserstackAccessKey ?? process.env.BROWSERSTACK_ACCESS_KEY ?? '',\n deviceName: opts.deviceModel ?? (opts.platform === 'android' ? 'Google Pixel 7' : 'iPhone 15'),\n osVersion: opts.osVersion ?? (opts.platform === 'android' ? '13.0' : '17'),\n realMobile: true,\n projectName: 'ZeTa-QIP',\n buildName: `${opts.platform}-crawl`,\n sessionName: opts.jobId,\n };\n if (opts.platform === 'android') {\n return {\n 'platformName': 'android',\n 'appium:automationName': 'UiAutomator2',\n 'appium:app': localAppPath,\n 'appium:newCommandTimeout': 120,\n 'appium:autoGrantPermissions': true,\n ...(opts.appActivity ? { 'appium:appActivity': opts.appActivity } : {}),\n ...(opts.packageName ? { 'appium:appPackage': opts.packageName } : {}),\n 'bstack:options': bstackOptions,\n };\n }\n return {\n 'platformName': 'ios',\n 'appium:automationName': 'XCUITest',\n 'appium:app': localAppPath,\n 'appium:newCommandTimeout': 120,\n ...(opts.packageName ? { 'appium:bundleId': opts.packageName } : {}),\n 'bstack:options': bstackOptions,\n };\n }\n\n if (opts.platform === 'android') {\n return {\n 'appium:platformName': 'Android',\n 'appium:deviceName': opts.deviceModel ?? 'Pixel 7',\n 'appium:app': localAppPath,\n 'appium:automationName': 'UiAutomator2',\n 'appium:autoGrantPermissions': true,\n 'appium:newCommandTimeout': 120,\n 'appium:platformVersion': opts.osVersion ?? '',\n ...(opts.appActivity ? { 'appium:appActivity': opts.appActivity } : {}),\n ...(opts.appWaitActivity ? { 'appium:appWaitActivity': opts.appWaitActivity } : {}),\n 'appium:eventTimings': true, // capture launch time telemetry\n ...(opts.packageName ? { 'appium:appPackage': opts.packageName } : {}),\n };\n }\n\n if (opts.platform === 'windows') {\n return {\n 'platformName': 'Windows',\n 'appium:automationName': 'Windows',\n 'appium:app': localAppPath,\n 'appium:deviceName': 'WindowsPC',\n 'appium:newCommandTimeout': 120,\n ...(opts.packageName ? { 'appium:appTopLevelWindow': opts.packageName } : {}),\n };\n }\n\n if (opts.platform === 'macos') {\n return {\n 'platformName': 'mac',\n 'appium:automationName': 'Mac2',\n 'appium:app': localAppPath,\n 'appium:deviceName': 'Mac',\n 'appium:newCommandTimeout': 120,\n ...(opts.packageName ? { 'appium:bundleId': opts.packageName } : {}),\n };\n }\n\n // iOS\n return {\n 'appium:platformName': 'iOS',\n 'appium:deviceName': opts.deviceModel ?? 'iPhone 15',\n 'appium:app': localAppPath,\n 'appium:automationName': 'XCUITest',\n 'appium:newCommandTimeout': 120,\n 'appium:platformVersion': opts.osVersion ?? '',\n ...(opts.packageName ? { 'appium:bundleId': opts.packageName } : {}),\n };\n}\n\n/**\n * Upload a locally downloaded app binary to BrowserStack App Automate.\n * Returns the bs:// app URL to use as the Appium capability value.\n */\nasync function uploadToBrowserStack(localPath: string, opts: NativeCrawlerOptions): Promise<string> {\n const username = opts.browserstackUsername ?? process.env.BROWSERSTACK_USERNAME ?? '';\n const accessKey = opts.browserstackAccessKey ?? process.env.BROWSERSTACK_ACCESS_KEY ?? '';\n const credentials = Buffer.from(`${username}:${accessKey}`).toString('base64');\n\n const fileBuffer = await fs.readFile(localPath);\n const filename = localPath.split('/').pop() ?? 'app.apk';\n\n const form = new (global as any).FormData();\n form.append('file', new Blob([new Uint8Array(fileBuffer)]), filename);\n\n logger.info({ localPath, filename }, 'Uploading app binary to BrowserStack');\n const response = await fetch('https://api-cloud.browserstack.com/app-automate/upload', {\n method: 'POST',\n headers: { Authorization: `Basic ${credentials}` },\n body: form,\n });\n\n if (!response.ok) {\n throw new Error(`BrowserStack upload failed: ${response.status} ${await response.text()}`);\n }\n const json = (await response.json()) as { app_url?: string };\n if (!json.app_url) throw new Error('BrowserStack upload did not return app_url');\n logger.info({ appUrl: json.app_url }, 'App uploaded to BrowserStack');\n return json.app_url; // e.g. \"bs://1234abc...\"\n}\n\n/**\n * Download app binary from S3 to a local temp file.\n * For BrowserStack, also uploads the binary to App Automate and returns the bs:// URL.\n * Returns the local file path (or bs:// URL for BrowserStack).\n */\nasync function downloadAppFromS3(appS3Path: string, platform: 'android' | 'ios' | 'windows' | 'macos', opts: NativeCrawlerOptions, storageCfg?: TenantStorageConfig | null): Promise<string> {\n // Local disk fallback \u2014 APK stored on-server when no cloud storage is configured\n if (appS3Path.startsWith('local://')) {\n const localPath = appS3Path.slice('local://'.length);\n logger.info({ localPath }, 'Using locally stored app binary');\n if (opts.deviceFarm === 'browserstack') {\n return uploadToBrowserStack(localPath, opts);\n }\n return localPath;\n }\n\n // Parse s3://bucket/key\n const withoutScheme = appS3Path.replace(/^s3:\\/\\//, '');\n const slashIdx = withoutScheme.indexOf('/');\n if (slashIdx === -1) {\n throw new Error(`Invalid S3 path: ${appS3Path}. Expected format: s3://bucket/path/to/file`);\n }\n const bucket = withoutScheme.slice(0, slashIdx);\n const key = withoutScheme.slice(slashIdx + 1);\n const extMap: Record<string, string> = { android: '.apk', ios: '.ipa', windows: '.exe', macos: '.app' };\n const ext = extMap[platform] ?? '';\n const tmpPath = path.join(os.tmpdir(), `zeta-native-${Date.now()}${ext}`);\n\n logger.info({ bucket, key, tmpPath, provider: storageCfg?.provider ?? 'env' }, 'Downloading app binary from storage');\n\n const { S3Client, GetObjectCommand } = await import('@aws-sdk/client-s3' as any);\n const isR2 = storageCfg?.provider === 'R2';\n const s3ClientCfg: Record<string, any> = {\n region: isR2 ? 'auto' : (storageCfg?.region ?? process.env.AWS_REGION ?? 'us-east-1'),\n };\n if (storageCfg?.accessKeyId && storageCfg?.secretAccessKey) {\n s3ClientCfg.credentials = { accessKeyId: storageCfg.accessKeyId, secretAccessKey: storageCfg.secretAccessKey };\n }\n if (isR2 && storageCfg?.accountId) {\n s3ClientCfg.endpoint = `https://${storageCfg.accountId}.r2.cloudflarestorage.com`;\n s3ClientCfg.forcePathStyle = true;\n }\n const s3 = new S3Client(s3ClientCfg);\n const response = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));\n\n // Stream the body to disk\n const { Readable } = await import('node:stream');\n const { pipeline } = await import('node:stream/promises');\n const fh = await fs.open(tmpPath, 'w');\n try {\n await pipeline(Readable.from(response.Body as any), fh.createWriteStream());\n } finally {\n await fh.close();\n }\n\n logger.info({ tmpPath }, 'App binary downloaded');\n\n // For BrowserStack, upload the binary and return the bs:// URL\n if (opts.deviceFarm === 'browserstack') {\n return uploadToBrowserStack(tmpPath, opts);\n }\n return tmpPath;\n}\n\n/**\n * Parse the Appium XML page source into a flat list of NativeElement records.\n *\n * Android UiAutomator2 XML looks like:\n * <node text=\"Login\" resource-id=\"com.example:id/btn_login\"\n * class=\"android.widget.Button\" bounds=\"[100,400][980,520]\"\n * clickable=\"true\" enabled=\"true\" />\n *\n * iOS XCUITest XML looks like:\n * <XCUIElementTypeButton name=\"Login\" label=\"Login\"\n * x=\"50\" y=\"300\" width=\"280\" height=\"44\"\n * enabled=\"true\" visible=\"true\" />\n */\nfunction parseAccessibilityTree(xmlSource: string, platform: 'android' | 'ios' | 'windows' | 'macos'): NativeElement[] {\n const elements: NativeElement[] = [];\n\n if (platform === 'windows') {\n // WinAppDriver returns UIAutomation XML like:\n // <Button Name=\"OK\" AutomationId=\"OkButton\" IsEnabled=\"true\" IsOffscreen=\"false\"/>\n const WIN_INTERACTIVE = new Set(['Button', 'Edit', 'ComboBox', 'CheckBox', 'RadioButton', 'Slider', 'ListItem', 'MenuItem', 'Hyperlink']);\n const nodeRegex = /<(\\w+)\\s([^>]*)\\/>/g;\n let match: RegExpExecArray | null;\n while ((match = nodeRegex.exec(xmlSource)) !== null) {\n const typeName = match[1] ?? '';\n if (!WIN_INTERACTIVE.has(typeName)) continue;\n const attrs = match[2] ?? '';\n const get = (name: string) => new RegExp(`\\\\b${name}=\"([^\"]*)\"`, 'i').exec(attrs)?.[1] ?? '';\n if (get('IsEnabled') === 'false' || get('IsOffscreen') === 'true') continue;\n const label = get('Name') || get('AutomationId') || '';\n if (!label) continue;\n const role = winTypeToRole(typeName);\n // BoundingRectangle=\"x:0 y:0 width:100 height:30\" or similar\n const brStr = get('BoundingRectangle');\n const brMatch = /x:(\\d+)\\s+y:(\\d+)\\s+width:(\\d+)\\s+height:(\\d+)/i.exec(brStr);\n const bounds = brMatch\n ? { x: parseInt(brMatch[1]), y: parseInt(brMatch[2]), width: parseInt(brMatch[3]), height: parseInt(brMatch[4]) }\n : { x: 0, y: 0, width: 100, height: 30 };\n elements.push({ role, label, bounds, platform: 'windows' as any, className: typeName });\n }\n }\n\n if (platform === 'macos') {\n const MAC_INTERACTIVE = new Set([\n 'XCUIElementTypeButton', 'XCUIElementTypeTextField', 'XCUIElementTypeSecureTextField',\n 'XCUIElementTypeCheckBox', 'XCUIElementTypeRadioButton', 'XCUIElementTypePopUpButton',\n 'XCUIElementTypeComboBox', 'XCUIElementTypeSlider', 'XCUIElementTypeMenuItem',\n 'XCUIElementTypeSearchField', 'XCUIElementTypeLink',\n ]);\n const nodeRegex = /<(XCUIElementType\\w+)\\s([^>]*)\\/>/g;\n let match: RegExpExecArray | null;\n while ((match = nodeRegex.exec(xmlSource)) !== null) {\n const typeName = match[1] ?? '';\n if (!MAC_INTERACTIVE.has(typeName)) continue;\n const attrs = match[2] ?? '';\n const get = (name: string) => new RegExp(`\\\\b${name}=\"([^\"]*)\"`, 'i').exec(attrs)?.[1] ?? '';\n if (get('enabled') === 'false') continue;\n const label = get('title') || get('label') || get('value') || '';\n if (!label) continue;\n const x = parseFloat(get('x') || '0');\n const y = parseFloat(get('y') || '0');\n const width = parseFloat(get('width') || '0');\n const height = parseFloat(get('height') || '0');\n const role = macTypeToRole(typeName);\n elements.push({ role, label, bounds: { x, y, width, height }, platform: 'macos' as any, className: typeName });\n }\n }\n\n if (platform === 'android') {\n // Match interactive node elements\n const INTERACTIVE_CLASSES = new Set([\n 'android.widget.Button',\n 'android.widget.EditText',\n 'android.widget.CheckBox',\n 'android.widget.RadioButton',\n 'android.widget.Switch',\n 'android.widget.Spinner',\n 'android.widget.TextView', // tappable text\n 'android.widget.ImageButton',\n 'android.widget.ToggleButton',\n 'androidx.recyclerview.widget.RecyclerView',\n 'com.google.android.material.bottomnavigation.BottomNavigationItemView',\n 'android.widget.ImageView', // tappable images (nav icons)\n ]);\n\n // Simple regex-based node extraction \u2014 avoids adding an XML parser dependency.\n // This is intentionally minimal; a full implementation would use a proper XML parser.\n const nodeRegex = /<node\\s([^>]*)\\/?>/g;\n let match: RegExpExecArray | null;\n while ((match = nodeRegex.exec(xmlSource)) !== null) {\n const attrs = match[1] ?? '';\n const get = (name: string): string => {\n const m = new RegExp(`\\\\b${name}=\"([^\"]*)\"`, 'i').exec(attrs);\n return m ? m[1] ?? '' : '';\n };\n\n const className = get('class');\n const clickable = get('clickable') === 'true';\n const enabled = get('enabled') === 'true';\n\n if (!enabled) continue;\n if (!clickable && !INTERACTIVE_CLASSES.has(className)) continue;\n\n const label = get('content-desc') || get('text') || get('resource-id').split('/').pop() || '';\n if (!label) continue;\n\n const resourceId = get('resource-id');\n const boundsStr = get('bounds'); // \"[x1,y1][x2,y2]\"\n const bounds = parseAndroidBounds(boundsStr);\n if (!bounds) continue;\n\n const role = classToRole(className);\n elements.push({ role, label, bounds, platform: 'android', resourceId, className });\n }\n } else {\n // iOS \u2014 XCUITest element types\n const INTERACTIVE_TYPES = new Set([\n 'XCUIElementTypeButton',\n 'XCUIElementTypeTextField',\n 'XCUIElementTypeSecureTextField',\n 'XCUIElementTypeSwitch',\n 'XCUIElementTypeSlider',\n 'XCUIElementTypeCheckBox',\n 'XCUIElementTypeRadioButton',\n 'XCUIElementTypePickerWheel',\n 'XCUIElementTypeSearchField',\n 'XCUIElementTypeCell',\n 'XCUIElementTypeLink',\n 'XCUIElementTypeTabBarItem',\n 'XCUIElementTypeNavigationBar',\n ]);\n\n const nodeRegex = /<(XCUIElementType\\w+)\\s([^>]*)\\/>/g;\n let match: RegExpExecArray | null;\n while ((match = nodeRegex.exec(xmlSource)) !== null) {\n const typeName = match[1] ?? '';\n if (!INTERACTIVE_TYPES.has(typeName)) continue;\n\n const attrs = match[2] ?? '';\n const get = (name: string): string => {\n const m = new RegExp(`\\\\b${name}=\"([^\"]*)\"`, 'i').exec(attrs);\n return m ? m[1] ?? '' : '';\n };\n\n if (get('enabled') === 'false') continue;\n\n const label = get('label') || get('name') || get('value') || '';\n if (!label) continue;\n\n const x = parseFloat(get('x') || '0');\n const y = parseFloat(get('y') || '0');\n const width = parseFloat(get('width') || '0');\n const height = parseFloat(get('height') || '0');\n if (width === 0 && height === 0) continue;\n\n const role = xcuiTypeToRole(typeName);\n elements.push({ role, label, bounds: { x, y, width, height }, platform: 'ios', className: typeName });\n }\n }\n\n return elements;\n}\n\n/** Parse Android bounds string \"[x1,y1][x2,y2]\" \u2192 NativeBounds */\nfunction parseAndroidBounds(boundsStr: string): NativeBounds | null {\n const m = /\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]/.exec(boundsStr);\n if (!m) return null;\n const x1 = parseInt(m[1] ?? '0', 10);\n const y1 = parseInt(m[2] ?? '0', 10);\n const x2 = parseInt(m[3] ?? '0', 10);\n const y2 = parseInt(m[4] ?? '0', 10);\n return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };\n}\n\n/** Map Android widget class names to ZeTa element roles */\nfunction classToRole(className: string): string {\n if (className.includes('Button')) return 'button';\n if (className.includes('EditText')) return 'input';\n if (className.includes('CheckBox')) return 'checkbox';\n if (className.includes('RadioButton')) return 'radio';\n if (className.includes('Switch') || className.includes('Toggle')) return 'switch';\n if (className.includes('Spinner')) return 'select';\n if (className.includes('TextView')) return 'text';\n return className.split('.').pop()?.toLowerCase() ?? 'unknown';\n}\n\n/** Map XCUITest element type strings to ZeTa element roles */\nfunction xcuiTypeToRole(xcuiType: string): string {\n if (xcuiType.includes('Button')) return 'button';\n if (xcuiType.includes('TextField')) return 'input';\n if (xcuiType.includes('SecureTextField')) return 'input';\n if (xcuiType.includes('Switch')) return 'switch';\n if (xcuiType.includes('Slider')) return 'slider';\n if (xcuiType.includes('CheckBox')) return 'checkbox';\n if (xcuiType.includes('RadioButton')) return 'radio';\n if (xcuiType.includes('PickerWheel')) return 'select';\n if (xcuiType.includes('SearchField')) return 'input';\n return xcuiType.replace('XCUIElementType', '').toLowerCase();\n}\n\n/** Map WinAppDriver UIAutomation element type names to ZeTa element roles */\nfunction winTypeToRole(winType: string): string {\n if (winType === 'Button') return 'button';\n if (winType === 'Edit') return 'input';\n if (winType === 'ComboBox') return 'select';\n if (winType === 'CheckBox') return 'checkbox';\n if (winType === 'RadioButton') return 'radio';\n if (winType === 'Slider') return 'slider';\n if (winType === 'ListItem') return 'option';\n if (winType === 'MenuItem') return 'menuitem';\n if (winType === 'Hyperlink') return 'link';\n return winType.toLowerCase();\n}\n\n/** Map Mac2Driver XCUIElementType strings to ZeTa element roles */\nfunction macTypeToRole(xcuiType: string): string {\n if (xcuiType.includes('Button')) return 'button';\n if (xcuiType.includes('SecureTextField')) return 'input';\n if (xcuiType.includes('TextField')) return 'input';\n if (xcuiType.includes('CheckBox')) return 'checkbox';\n if (xcuiType.includes('RadioButton')) return 'radio';\n if (xcuiType.includes('PopUpButton')) return 'select';\n if (xcuiType.includes('ComboBox')) return 'select';\n if (xcuiType.includes('Slider')) return 'slider';\n if (xcuiType.includes('MenuItem')) return 'menuitem';\n if (xcuiType.includes('SearchField')) return 'input';\n if (xcuiType.includes('Link')) return 'link';\n return xcuiType.replace('XCUIElementType', '').toLowerCase();\n}\n\n// \u2500\u2500\u2500 Gap 1: Native Auth \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Detect if the current screen is a login/auth screen and fill credentials. */\nasync function attemptNativeLogin(\n driver: any,\n platform: 'android' | 'ios' | 'windows' | 'macos',\n credentials: NonNullable<NativeCrawlerOptions['credentials']>,\n): Promise<boolean> {\n try {\n const pageXml: string = await driver.getPageSource();\n const elements = parseAccessibilityTree(pageXml, platform);\n\n const inputEls = elements.filter((e) => e.role === 'input');\n if (inputEls.length === 0) return false;\n\n // Detect login screen heuristic: has at least one text input and one\n // \"password\" input (SecureTextField on iOS, password type on Android)\n const isPasswordField = (e: NativeElement) =>\n e.className?.toLowerCase().includes('secure') ||\n e.className?.toLowerCase().includes('password') ||\n e.label?.toLowerCase().match(/password|passcode|pin|secret/i) != null;\n\n const hasPasswordInput = elements.some(isPasswordField);\n if (!hasPasswordInput && inputEls.length < 2) return false;\n\n logger.info({ platform }, 'Native auth: login screen detected \u2014 filling credentials');\n\n // Fill username field: first non-password input\n const unLabel = credentials.usernameFieldLabel?.toLowerCase() ?? '';\n const usernameEl = inputEls.find((e) => {\n if (isPasswordField(e)) return false;\n if (unLabel) return e.label?.toLowerCase().includes(unLabel);\n return e.label?.toLowerCase().match(/email|username|user|login|phone|mobile/i) != null || true;\n }) ?? inputEls[0];\n\n if (usernameEl && credentials.username) {\n const uSelector = usernameEl.resourceId\n ? `id=${usernameEl.resourceId}`\n : `~${usernameEl.label}`;\n try {\n const uEl = await driver.$(uSelector);\n await uEl.click();\n await driver.pause(300);\n await uEl.clearValue();\n await uEl.setValue(credentials.username);\n } catch { /* non-fatal */ }\n }\n\n // Fill password field\n const pwEl = elements.find(isPasswordField);\n if (pwEl && credentials.password) {\n const pSelector = pwEl.resourceId ? `id=${pwEl.resourceId}` : `~${pwEl.label}`;\n try {\n const pEl = await driver.$(pSelector);\n await pEl.click();\n await driver.pause(300);\n await pEl.clearValue();\n await pEl.setValue(credentials.password);\n } catch { /* non-fatal */ }\n }\n\n // Dismiss keyboard then tap login button\n try { await driver.hideKeyboard(); } catch { /* not always available */ }\n await driver.pause(500);\n\n const loginLabel = credentials.loginButtonLabel ?? '';\n const loginBtn = elements.find((e) =>\n e.role === 'button' &&\n (loginLabel\n ? e.label?.toLowerCase().includes(loginLabel.toLowerCase())\n : e.label?.toLowerCase().match(/log\\s?in|sign\\s?in|submit|continue|next|connect/i) != null)\n );\n if (loginBtn) {\n const btnSelector = loginBtn.resourceId ? `id=${loginBtn.resourceId}` : `~${loginBtn.label}`;\n try {\n await driver.$(btnSelector).click();\n await driver.pause(3000); // wait for auth response\n logger.info({ platform }, 'Native auth: login button tapped \u2014 waiting for app');\n return true;\n } catch { /* non-fatal */ }\n }\n return false;\n } catch (err) {\n logger.warn({ err }, 'Native auth attempt failed \u2014 continuing without login');\n return false;\n }\n}\n\n// \u2500\u2500\u2500 Gap 3: Gesture Navigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Perform a swipe gesture on the screen. */\nasync function performSwipe(\n driver: any,\n platform: 'android' | 'ios' | 'windows' | 'macos',\n direction: 'left' | 'right' | 'up' | 'down',\n): Promise<void> {\n try {\n const size: { width: number; height: number } = await driver.getWindowSize();\n const { width: W, height: H } = size;\n const midX = Math.round(W / 2);\n const midY = Math.round(H / 2);\n\n if (platform === 'android') {\n // Use W3C actions for Android\n const startX = direction === 'left' ? Math.round(W * 0.8) : direction === 'right' ? Math.round(W * 0.2) : midX;\n const startY = direction === 'up' ? Math.round(H * 0.8) : direction === 'down' ? Math.round(H * 0.2) : midY;\n const endX = direction === 'left' ? Math.round(W * 0.2) : direction === 'right' ? Math.round(W * 0.8) : midX;\n const endY = direction === 'up' ? Math.round(H * 0.2) : direction === 'down' ? Math.round(H * 0.8) : midY;\n await driver.touchAction([\n { action: 'press', x: startX, y: startY },\n { action: 'wait', ms: 300 },\n { action: 'moveTo', x: endX, y: endY },\n { action: 'release' },\n ]);\n } else if (platform === 'ios') {\n await driver.execute('mobile: swipe', { direction });\n } else {\n // Windows/macOS: use pointer actions\n await driver.execute('mobile: scroll', { direction });\n }\n await driver.pause(500);\n } catch { /* gesture not supported on this platform/driver \u2014 non-fatal */ }\n}\n\n/** Long-press an element by its selector. */\nasync function performLongPress(driver: any, selector: string, durationMs = 1200): Promise<void> {\n try {\n const el = await driver.$(selector);\n await driver.touchAction([\n { action: 'longPress', element: el, ms: durationMs },\n { action: 'release' },\n ]);\n await driver.pause(600);\n } catch { /* non-fatal */ }\n}\n\n// \u2500\u2500\u2500 Gap 2: Vision Fallback \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** When the accessibility tree yields zero interactive elements, use LLM vision to describe the screen. */\nasync function visionFallbackDescribeScreen(\n screenshotBase64: string,\n llmConfig: { provider: string; model: string; apiKey?: string; baseUrl?: string },\n): Promise<{ screenDescription: string; suggestedElements: Array<{ role: string; label: string }> }> {\n try {\n const Anthropic = (await import('@anthropic-ai/sdk')).default;\n if (!llmConfig.apiKey) throw new Error('No LLM API key configured. Configure an Anthropic key in Workspace \u2192 Settings \u2192 AI Provider.');\n const client = new Anthropic({ apiKey: llmConfig.apiKey });\n const response = await client.messages.create({\n model: llmConfig.model ?? 'claude-haiku-4-5-20251001',\n max_tokens: 512,\n messages: [{\n role: 'user',\n content: [\n { type: 'image', source: { type: 'base64', media_type: 'image/png', data: screenshotBase64 } },\n { type: 'text', text: 'This is a screenshot from a mobile app. The accessibility tree returned no elements. Describe what you see on screen in 1-2 sentences, then list ALL interactive UI elements (buttons, inputs, links, toggles) as JSON: [{\"role\":\"button\",\"label\":\"Sign In\"},...]' },\n ],\n }],\n });\n const text = (response.content[0] as any)?.text ?? '';\n const jsonMatch = text.match(/\\[[\\s\\S]*\\]/);\n const elements = jsonMatch ? JSON.parse(jsonMatch[0]) : [];\n return { screenDescription: text.split('[')[0]?.trim() ?? '', suggestedElements: elements };\n } catch (err) {\n logger.warn({ err }, 'Vision fallback LLM call failed');\n }\n return { screenDescription: 'Screen captured (accessibility tree empty \u2014 vision fallback unavailable)', suggestedElements: [] };\n}\n\n// \u2500\u2500\u2500 Gap 4: MITM Proxy API Capture \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Start a lightweight proxy server to intercept native app API traffic. */\nasync function startMitmProxy(port: number): Promise<{ captured: Array<{ method: string; url: string; status?: number }>; stop: () => void }> {\n const captured: Array<{ method: string; url: string; status?: number }> = [];\n try {\n const net = await import('net');\n // Simple TCP intercept marker \u2014 full MITM requires SSL cert installation on device.\n // This records connection attempts; full response interception requires device trust cert.\n const server = net.createServer((socket) => {\n socket.once('data', (data) => {\n const raw = data.toString('utf8', 0, 512);\n const match = /^(GET|POST|PUT|DELETE|PATCH|HEAD)\\s+(https?:\\/\\/[^\\s]+)/i.exec(raw);\n if (match) captured.push({ method: match[1]!, url: match[2]! });\n socket.destroy();\n });\n });\n server.listen(port, '127.0.0.1');\n const stop = () => server.close();\n logger.info({ port }, 'MITM proxy listener started (connection-level only \u2014 install device trust cert for full interception)');\n return { captured, stop };\n } catch (err) {\n logger.warn({ err }, 'MITM proxy start failed \u2014 API capture disabled');\n return { captured, stop: () => {} };\n }\n}\n\n/** Update the PlatformJob status/progress in DB \u2014 non-fatal. */\nasync function updateProgress(tenantId: string, jobId: string, progress: number, step: string): Promise<void> {\n try {\n await withTenant(tenantId, (tx) =>\n tx.platformJob.update({\n where: { id: jobId },\n data: { progress, result: { step } as any },\n })\n );\n } catch (err) {\n logger.warn({ err }, 'Failed to update job progress (non-fatal)');\n }\n}\n\n// ---------------------------------------------------------------------------\n// Main export\n// ---------------------------------------------------------------------------\n\n/**\n * Run a native app crawl session via Appium.\n *\n * Flow:\n * 1. Download APK/IPA from S3 to local temp dir\n * 2. Build Appium WebDriverIO capabilities for the target platform\n * 3. Create WebDriverIO remote session (Appium server at APPIUM_SERVER_URL)\n * 4. For each discovered screen:\n * a. Get page source XML \u2192 parse elements\n * b. Take screenshot \u2192 save to screenshotDir \u2192 persist via AppBrain\n * c. Save screen + elements to DB\n * d. Tap nav elements to discover next screens\n * 5. Update PlatformJob as DONE (or FAILED on error)\n */\nexport async function runNativeCrawl(opts: NativeCrawlerOptions): Promise<void> {\n const { tenantId, projectId, jobId, platform } = opts;\n const screenshotDir = path.join(config.screenshotDir, projectId);\n await fs.mkdir(screenshotDir, { recursive: true });\n\n let tmpAppPath: string | null = null;\n\n // Mark job RUNNING\n await withTenant(tenantId, (tx) =>\n tx.platformJob.update({\n where: { id: jobId },\n data: { status: 'RUNNING', startedAt: new Date() },\n })\n );\n\n const tenantStorageCfg = await getTenantStorageConfig(tenantId).catch(() => null);\n\n try {\n // \u2500\u2500 Step 1: Download app binary from S3 / R2 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n await updateProgress(tenantId, jobId, 5, 'Downloading app binary');\n tmpAppPath = await downloadAppFromS3(opts.appS3Path, platform, opts, tenantStorageCfg);\n\n // \u2500\u2500 Step 2: Build Appium capabilities \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n await updateProgress(tenantId, jobId, 10, 'Building Appium session capabilities');\n const capabilities = buildCapabilities(opts, tmpAppPath);\n\n // \u2500\u2500 Step 3: Start WebDriverIO session \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // TODO: add \"webdriverio\": \"^9.x\" to packages/crawler/package.json\n // TODO: requires Appium server running at APPIUM_SERVER_URL env var\n await updateProgress(tenantId, jobId, 15, 'Starting Appium session');\n\n const appiumServerUrl = opts.appiumServerUrl ?? process.env.APPIUM_SERVER_URL ?? 'http://127.0.0.1:4723';\n\n // Pre-check connectivity before wdio throws an opaque EAI_AGAIN\n try {\n const probe = await fetch(`${appiumServerUrl}/status`, { signal: AbortSignal.timeout(5000) });\n if (!probe.ok) throw new Error(`HTTP ${probe.status}`);\n } catch (e: any) {\n throw new Error(\n `Appium server at ${appiumServerUrl} is not reachable (${e.message}). ` +\n `Start it with: cd /opt/zeta-ai && docker compose -f docker-compose.prod.yml -f docker-compose.appium.yml up -d appium`\n );\n }\n\n // TODO: add \"webdriverio\": \"^9.x\" to packages/crawler/package.json before deploying.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const wdio: any = await import('webdriverio' as any);\n const driver = await wdio.remote({\n hostname: new URL(appiumServerUrl).hostname,\n port: parseInt(new URL(appiumServerUrl).port || '4723', 10),\n path: '/',\n capabilities: capabilities as any,\n logLevel: 'warn',\n });\n\n logger.info({ platform, deviceFarm: opts.deviceFarm }, 'Appium session started');\n\n try {\n // Update DB with Appium session ID\n const sessionId: string = driver.sessionId;\n await withTenant(tenantId, (tx) =>\n (tx as any).platformJob.update({\n where: { id: jobId },\n data: { appiumSessionId: sessionId },\n })\n );\n\n // \u2500\u2500 Step 4: Crawl screens \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n await updateProgress(tenantId, jobId, 20, 'App launched \u2014 extracting initial screen');\n\n // Gap 4: Start MITM proxy for API traffic capture\n let mitmStop: (() => void) | null = null;\n const mitmCaptured: Array<{ method: string; url: string; status?: number }> = [];\n if (opts.mitmProxyPort) {\n const mitm = await startMitmProxy(opts.mitmProxyPort);\n mitmStop = mitm.stop;\n mitmCaptured.push(...mitm.captured);\n }\n\n // Allow the app to reach its initial state\n await driver.pause(2000);\n\n // Gap 1: Attempt native login before BFS begins\n if (opts.credentials?.username && opts.credentials?.password) {\n const loggedIn = await attemptNativeLogin(driver, platform, opts.credentials);\n if (loggedIn) await driver.pause(2500); // wait for post-login navigation\n }\n\n // Capture launch screen then navigate forward via discoverable nav elements.\n const MAX_SCREENS = 30;\n let screensFound = 0;\n const tappedSelectors = new Set<string>();\n\n for (let i = 0; i < MAX_SCREENS; i++) {\n const pct = 20 + Math.round((i / MAX_SCREENS) * 60);\n await updateProgress(tenantId, jobId, pct, `Capturing screen ${i + 1}`);\n\n // a. Get screen title / name heuristic\n let screenName = `Screen ${i + 1}`;\n try {\n // Android: use current activity name; iOS: use app title from accessibility tree\n // Windows/macOS: use driver.getTitle() for the window title\n if (platform === 'android') {\n const activity: string = await (driver as any).getCurrentActivity();\n screenName = activity.split('.').pop() ?? screenName;\n } else if (platform === 'ios') {\n // iOS: attempt to read the navigation bar title\n const titleEl = await driver.$('~NavigationBarTitle').catch(() => null);\n if (titleEl) {\n const titleText = await (titleEl as any).getText().catch(() => '');\n if (titleText) screenName = titleText;\n }\n } else {\n // windows / macos: use window title\n const winTitle: string = await driver.getTitle().catch(() => '');\n if (winTitle) screenName = winTitle;\n }\n } catch { /* non-fatal \u2014 use default name */ }\n\n // b. Take screenshot\n const screenshotBase64: string = await driver.takeScreenshot();\n const screenshotBuffer = Buffer.from(screenshotBase64, 'base64');\n\n // DPR normalization: resize physical pixels \u2192 logical pixels\n let screenshotBuf = screenshotBuffer;\n try {\n const sharp = (await import('sharp')).default;\n const meta = await sharp(screenshotBuffer).metadata();\n const logicalSize: { width: number; height: number } = await (driver as any).getWindowSize().catch(() => ({ width: meta.width ?? 0, height: meta.height ?? 0 }));\n const physW = meta.width ?? 0;\n const logW = logicalSize.width;\n if (physW > 0 && logW > 0 && physW > logW) {\n const logH = Math.round((meta.height ?? physW) * (logW / physW));\n screenshotBuf = Buffer.from(await sharp(screenshotBuffer).resize(logW, logH, { fit: 'fill' }).png().toBuffer());\n }\n } catch { /* sharp unavailable or resize failed \u2014 use raw buffer */ }\n\n // Save screen record first (to get screen.id)\n const screen = await AppBrain.saveScreen(tenantId, projectId, screenName);\n const shotPath = path.join(screenshotDir, `${screen.id}-native-v${screen.version}.png`);\n await fs.writeFile(shotPath, screenshotBuf);\n let cloudUrl: string | undefined;\n if (tenantStorageCfg) {\n const s3Key = `${tenantId}/${projectId}/screenshots/${screen.id}-native-v${screen.version}.png`;\n cloudUrl = await uploadScreenshotToTenantStorage(tenantStorageCfg, shotPath, s3Key).catch(() => undefined);\n }\n await AppBrain.saveScreenshot(tenantId, projectId, screen.id, shotPath, screen.version, 'NATIVE', cloudUrl);\n\n // c. Parse accessibility tree \u2192 save elements\n // Scroll down to reveal below-fold elements, then scroll back up before parsing\n try {\n if (platform === 'android') {\n await driver.execute('mobile: scroll', { direction: 'down' });\n } else if (platform === 'ios') {\n await driver.execute('mobile: scroll', { direction: 'down', element: null });\n }\n await driver.pause(400);\n } catch { /* scroll not always available \u2014 non-fatal */ }\n\n const pageXml: string = await driver.getPageSource();\n const nativeElements = parseAccessibilityTree(pageXml, platform);\n try {\n if (platform === 'android') {\n await driver.execute('mobile: scroll', { direction: 'up' });\n } else if (platform === 'ios') {\n await driver.execute('mobile: scroll', { direction: 'up', element: null });\n }\n await driver.pause(300);\n } catch { /* non-fatal */ }\n logger.info({ screenName, elements: nativeElements.length }, 'Native screen parsed');\n\n // Gap 2: Vision fallback when accessibility tree is empty (Flutter / game engines)\n if (nativeElements.length === 0 && opts.visionLlmConfig) {\n logger.info({ screenName }, 'Empty accessibility tree \u2014 triggering vision LLM fallback');\n const { suggestedElements } = await visionFallbackDescribeScreen(screenshotBase64, opts.visionLlmConfig);\n for (const el of suggestedElements) {\n nativeElements.push({\n role: el.role, label: el.label, resourceId: '', className: '',\n bounds: { x: 0, y: 0, width: 0, height: 0 }, platform,\n });\n }\n }\n\n for (const el of nativeElements) {\n const boundingRect = {\n x: Math.round(el.bounds.x),\n y: Math.round(el.bounds.y),\n width: Math.round(el.bounds.width),\n height: Math.round(el.bounds.height),\n };\n const notes = JSON.stringify({\n ...(el.resourceId ? { resourceId: el.resourceId } : {}),\n ...(el.className ? { className: el.className } : {}),\n platform: el.platform,\n });\n await AppBrain.saveElement(\n tenantId,\n projectId,\n screen.id,\n el.label, // meaning\n el.role,\n undefined, // expectedData\n notes,\n undefined, // text\n true, // isInteractive\n boundingRect,\n undefined, // ariaState\n undefined, // parentLandmark\n 'NATIVE', // viewport\n );\n }\n\n screensFound++;\n\n // d. Navigate to next screen \u2014 basic BFS via nav-like elements\n const navElements = nativeElements.filter(el =>\n ['button', 'text'].includes(el.role) ||\n el.label?.match(/menu|home|back|next|continue|tab|nav|dashboard|settings|profile|more|list|search|help|about/i)\n );\n\n for (const navEl of navElements.slice(0, 15)) {\n if (screensFound >= MAX_SCREENS) break;\n try {\n // Build a selector for this element\n let selector: string;\n if (platform === 'android' && navEl.resourceId) {\n selector = `id=${navEl.resourceId}`;\n } else if (navEl.label) {\n selector = `~${navEl.label}`;\n } else {\n continue;\n }\n\n if (tappedSelectors.has(selector)) continue;\n tappedSelectors.add(selector);\n\n // Capture pre-tap screen identity\n let preActivity = '';\n try {\n if (platform === 'android') {\n preActivity = await (driver as any).getCurrentActivity();\n } else {\n preActivity = await driver.getTitle();\n }\n } catch { /* non-fatal */ }\n\n await driver.$(selector).click();\n await driver.pause(1500);\n\n // Android crash detection via logcat\n if (platform === 'android') {\n try {\n const logs: any[] = await (driver as any).getLogs('logcat');\n const crashLog = logs.find((l: any) =>\n typeof l.message === 'string' && /FATAL EXCEPTION|Process.*has died|ANR in/i.test(l.message)\n );\n if (crashLog) {\n logger.error({ jobId, crash: String(crashLog.message).slice(0, 500) }, 'App crash detected during crawl');\n break; // stop navigating from this screen\n }\n } catch { /* logcat not available on all environments \u2014 non-fatal */ }\n }\n // iOS crash log detection\n if (platform === 'ios') {\n const crashLogs: any[] = await (driver as any).getLogs('crashlog').catch(() => []);\n if (crashLogs.length > 0 && crashLogs.some((l: any) => l.message?.includes('Exception') || l.message?.includes('crash'))) {\n logger.warn({ screenName }, 'iOS crash log detected \u2014 stopping crawl');\n break;\n }\n }\n\n // Check if screen changed\n let postActivity = '';\n try {\n if (platform === 'android') {\n postActivity = await (driver as any).getCurrentActivity();\n } else {\n postActivity = await driver.getTitle();\n }\n } catch { /* non-fatal */ }\n\n if (postActivity === preActivity) continue; // same screen \u2014 skip\n\n // Check MAX_SCREENS before saving the new screen\n if (screensFound >= MAX_SCREENS) break;\n\n // New screen \u2014 capture it\n const newScreenName = postActivity\n ? postActivity.split('.').pop() ?? `Screen ${screensFound + 1}`\n : `Screen ${screensFound + 1}`;\n const newScreenshot: string = await driver.takeScreenshot();\n const newScreen = await AppBrain.saveScreen(tenantId, projectId, newScreenName);\n const newShotPath = path.join(screenshotDir, `${newScreen.id}-native-v${newScreen.version}.png`);\n await fs.writeFile(newShotPath, Buffer.from(newScreenshot, 'base64'));\n let newCloudUrl: string | undefined;\n if (tenantStorageCfg) {\n const s3Key = `${tenantId}/${projectId}/screenshots/${newScreen.id}-native-v${newScreen.version}.png`;\n newCloudUrl = await uploadScreenshotToTenantStorage(tenantStorageCfg, newShotPath, s3Key).catch(() => undefined);\n }\n await AppBrain.saveScreenshot(tenantId, projectId, newScreen.id, newShotPath, newScreen.version, 'NATIVE', newCloudUrl);\n\n const newXml: string = await driver.getPageSource();\n const newElements = parseAccessibilityTree(newXml, platform);\n for (const el of newElements) {\n await AppBrain.saveElement(\n tenantId, projectId, newScreen.id,\n el.label, el.role, undefined,\n JSON.stringify({ ...(el.resourceId ? { resourceId: el.resourceId } : {}), ...(el.className ? { className: el.className } : {}), platform: el.platform }),\n undefined, true,\n { x: Math.round(el.bounds.x), y: Math.round(el.bounds.y), width: Math.round(el.bounds.width), height: Math.round(el.bounds.height) },\n undefined, undefined, 'NATIVE',\n );\n }\n screensFound++;\n\n // Navigate back so subsequent nav attempts start from a known state\n try { await driver.back(); await driver.pause(800); } catch { /* non-fatal */ }\n } catch { /* element may have disappeared or tap failed \u2014 skip */ }\n }\n\n // Gap 3: Gesture-based discovery \u2014 swipe to reveal off-screen content\n if (opts.gestureConfig?.enableSwipe && screensFound < MAX_SCREENS) {\n for (const dir of ['left', 'right', 'up', 'down'] as const) {\n await performSwipe(driver, platform, dir);\n const postSwipeXml = await driver.getPageSource().catch(() => '');\n if (postSwipeXml && postSwipeXml !== pageXml) break; // new content found\n }\n }\n\n // Nav pass complete for this screen \u2014 continue BFS to next iteration.\n }\n\n // \u2500\u2500 Step 5: Mark job DONE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n if (mitmStop) mitmStop();\n await driver.deleteSession();\n await updateProgress(tenantId, jobId, 99, `Crawl complete \u2014 ${screensFound} screen(s) captured`);\n await withTenant(tenantId, (tx) =>\n tx.platformJob.update({\n where: { id: jobId },\n data: {\n status: 'DONE',\n progress: 100,\n finishedAt: new Date(),\n result: { screensFound, platform } as any,\n },\n })\n );\n logger.info({ jobId, screensFound, platform }, 'Native crawl complete');\n\n } catch (sessionErr) {\n // Best-effort: clean up Appium session before re-throwing\n try { await driver.deleteSession(); } catch { /* ignore */ }\n throw sessionErr;\n }\n\n } catch (err: any) {\n logger.error({ jobId, err: String(err) }, 'Native crawl failed');\n await withTenant(tenantId, (tx) =>\n tx.platformJob.update({\n where: { id: jobId },\n data: {\n status: 'FAILED',\n error: String(err?.message ?? err),\n finishedAt: new Date(),\n },\n })\n ).catch(() => {});\n throw err;\n } finally {\n // Clean up local temp file\n if (tmpAppPath) {\n fs.unlink(tmpAppPath).catch(() => {});\n }\n }\n}\n"],
|
|
4
|
+
"sourcesContent": ["/**\n * ZeTa Native App Crawler \u2014 Appium-based crawler for Android APK and iOS IPA.\n *\n * Architecture:\n * Appium Server (running at APPIUM_SERVER_URL) \u2192 WebDriverIO session\n * \u2192 UiAutomator2 (Android) / XCUITest (iOS)\n * \u2192 accessibility tree XML \u2192 ZeTa Element records\n *\n * TODO: requires Appium server running at APPIUM_SERVER_URL env var\n * TODO: requires `webdriverio` package \u2014 add \"webdriverio\": \"^9.x\" to package.json\n *\n * Device farm support:\n * - local_emulator: Appium server + local emulator/simulator\n * - aws_device_farm: presigned S3 URL uploaded to Device Farm, session via WDIO\n * - browserstack: BrowserStack Automate Appium endpoint\n */\n\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\nimport os from 'node:os';\nimport { withTenant } from '@taodigital/database';\nimport { AppBrain, getTenantStorageConfig, uploadScreenshotToTenantStorage, type TenantStorageConfig } from '@taodigital/app-brain';\nimport { config } from '@taodigital/core';\nimport { logger } from './logger.js';\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\nexport interface NativeCrawlerOptions {\n tenantId: string;\n projectId: string;\n jobId: string;\n platform: 'android' | 'ios' | 'windows' | 'macos';\n appS3Path: string; // s3://bucket/path/app.apk or s3://bucket/path/app.ipa\n packageName?: string; // com.example.app (Android) or bundle ID (iOS)\n deviceModel?: string; // optional device preference\n osVersion?: string; // Appium platformVersion (e.g. \"14.0\" for Android, \"17.0\" for iOS)\n appActivity?: string; // Android: activity to launch (e.g. \".MainActivity\")\n appWaitActivity?: string; // Android: activity to wait for after launch\n deviceFarm: 'local_emulator' | 'aws_device_farm' | 'browserstack';\n // Device farm connection credentials (from workspace settings, overrides env vars)\n appiumServerUrl?: string;\n browserstackUsername?: string;\n browserstackAccessKey?: string;\n awsAccessKeyId?: string;\n awsSecretAccessKey?: string;\n awsRegion?: string;\n awsDeviceFarmProjectArn?: string;\n // Auth credentials for Gap 1: native app login\n credentials?: {\n username?: string;\n password?: string;\n usernameFieldLabel?: string; // hint if default detection fails, e.g. \"Email\"\n passwordFieldLabel?: string;\n loginButtonLabel?: string;\n };\n // Vision fallback AI config for Gap 2: when accessibility tree is empty\n visionLlmConfig?: { provider: string; model: string; apiKey?: string; baseUrl?: string } | null;\n // Gesture config for Gap 3\n gestureConfig?: {\n enableSwipe?: boolean; // default true\n enableLongPress?: boolean; // default true\n };\n // MITM proxy for Gap 4 (when available)\n mitmProxyPort?: number;\n}\n\n/** A native UI element extracted from the accessibility tree. */\ninterface NativeElement {\n role: string;\n label: string;\n bounds: NativeBounds;\n platform: 'android' | 'ios' | 'windows' | 'macos';\n resourceId?: string; // Android: resource-id attribute\n className?: string; // Android: widget class / iOS: XCUIElement type\n}\n\ninterface NativeBounds {\n x: number;\n y: number;\n width: number;\n height: number;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Build Appium desired capabilities for the given platform.\n * localAppPath is the local path after downloading from S3 (or a presigned URL\n * for device farms that fetch the binary themselves).\n */\nfunction buildCapabilities(opts: NativeCrawlerOptions, localAppPath: string): Record<string, unknown> {\n const appiumServerUrl = opts.appiumServerUrl ?? process.env.APPIUM_SERVER_URL ?? 'http://127.0.0.1:4723';\n logger.info({ appiumServerUrl, platform: opts.platform }, 'Building Appium capabilities');\n\n // BrowserStack early-return: https://hub-cloud.browserstack.com/wd/hub\n if (opts.deviceFarm === 'browserstack' && (opts.platform === 'android' || opts.platform === 'ios')) {\n const bstackOptions = {\n userName: opts.browserstackUsername ?? process.env.BROWSERSTACK_USERNAME ?? '',\n accessKey: opts.browserstackAccessKey ?? process.env.BROWSERSTACK_ACCESS_KEY ?? '',\n deviceName: opts.deviceModel ?? (opts.platform === 'android' ? 'Google Pixel 7' : 'iPhone 15'),\n osVersion: opts.osVersion ?? (opts.platform === 'android' ? '13.0' : '17'),\n realMobile: true,\n projectName: 'ZeTa-QIP',\n buildName: `${opts.platform}-crawl`,\n sessionName: opts.jobId,\n };\n if (opts.platform === 'android') {\n return {\n 'platformName': 'android',\n 'appium:automationName': 'UiAutomator2',\n 'appium:app': localAppPath,\n 'appium:newCommandTimeout': 120,\n 'appium:autoGrantPermissions': true,\n ...(opts.appActivity ? { 'appium:appActivity': opts.appActivity } : {}),\n ...(opts.packageName ? { 'appium:appPackage': opts.packageName } : {}),\n 'bstack:options': bstackOptions,\n };\n }\n return {\n 'platformName': 'ios',\n 'appium:automationName': 'XCUITest',\n 'appium:app': localAppPath,\n 'appium:newCommandTimeout': 120,\n ...(opts.packageName ? { 'appium:bundleId': opts.packageName } : {}),\n 'bstack:options': bstackOptions,\n };\n }\n\n if (opts.platform === 'android') {\n return {\n 'appium:platformName': 'Android',\n 'appium:deviceName': opts.deviceModel ?? 'Pixel 7',\n 'appium:app': localAppPath,\n 'appium:automationName': 'UiAutomator2',\n 'appium:autoGrantPermissions': true,\n 'appium:newCommandTimeout': 120,\n 'appium:platformVersion': opts.osVersion ?? '',\n ...(opts.appActivity ? { 'appium:appActivity': opts.appActivity } : {}),\n ...(opts.appWaitActivity ? { 'appium:appWaitActivity': opts.appWaitActivity } : {}),\n 'appium:eventTimings': true, // capture launch time telemetry\n ...(opts.packageName ? { 'appium:appPackage': opts.packageName } : {}),\n };\n }\n\n if (opts.platform === 'windows') {\n return {\n 'platformName': 'Windows',\n 'appium:automationName': 'Windows',\n 'appium:app': localAppPath,\n 'appium:deviceName': 'WindowsPC',\n 'appium:newCommandTimeout': 120,\n ...(opts.packageName ? { 'appium:appTopLevelWindow': opts.packageName } : {}),\n };\n }\n\n if (opts.platform === 'macos') {\n return {\n 'platformName': 'mac',\n 'appium:automationName': 'Mac2',\n 'appium:app': localAppPath,\n 'appium:deviceName': 'Mac',\n 'appium:newCommandTimeout': 120,\n ...(opts.packageName ? { 'appium:bundleId': opts.packageName } : {}),\n };\n }\n\n // iOS\n return {\n 'appium:platformName': 'iOS',\n 'appium:deviceName': opts.deviceModel ?? 'iPhone 15',\n 'appium:app': localAppPath,\n 'appium:automationName': 'XCUITest',\n 'appium:newCommandTimeout': 120,\n 'appium:platformVersion': opts.osVersion ?? '',\n ...(opts.packageName ? { 'appium:bundleId': opts.packageName } : {}),\n };\n}\n\n/**\n * Upload a locally downloaded app binary to BrowserStack App Automate.\n * Returns the bs:// app URL to use as the Appium capability value.\n */\nasync function uploadToBrowserStack(localPath: string, opts: NativeCrawlerOptions): Promise<string> {\n const username = opts.browserstackUsername ?? process.env.BROWSERSTACK_USERNAME ?? '';\n const accessKey = opts.browserstackAccessKey ?? process.env.BROWSERSTACK_ACCESS_KEY ?? '';\n const credentials = Buffer.from(`${username}:${accessKey}`).toString('base64');\n\n const fileBuffer = await fs.readFile(localPath);\n const filename = localPath.split('/').pop() ?? 'app.apk';\n\n const form = new (global as any).FormData();\n form.append('file', new Blob([new Uint8Array(fileBuffer)]), filename);\n\n logger.info({ localPath, filename }, 'Uploading app binary to BrowserStack');\n const response = await fetch('https://api-cloud.browserstack.com/app-automate/upload', {\n method: 'POST',\n headers: { Authorization: `Basic ${credentials}` },\n body: form,\n });\n\n if (!response.ok) {\n throw new Error(`BrowserStack upload failed: ${response.status} ${await response.text()}`);\n }\n const json = (await response.json()) as { app_url?: string };\n if (!json.app_url) throw new Error('BrowserStack upload did not return app_url');\n logger.info({ appUrl: json.app_url }, 'App uploaded to BrowserStack');\n return json.app_url; // e.g. \"bs://1234abc...\"\n}\n\n/**\n * Download app binary from S3 to a local temp file.\n * For BrowserStack, also uploads the binary to App Automate and returns the bs:// URL.\n * Returns the local file path (or bs:// URL for BrowserStack).\n */\nasync function downloadAppFromS3(appS3Path: string, platform: 'android' | 'ios' | 'windows' | 'macos', opts: NativeCrawlerOptions, storageCfg?: TenantStorageConfig | null): Promise<string> {\n // Local disk fallback \u2014 APK stored on-server when no cloud storage is configured\n if (appS3Path.startsWith('local://')) {\n const localPath = appS3Path.slice('local://'.length);\n logger.info({ localPath }, 'Using locally stored app binary');\n if (opts.deviceFarm === 'browserstack') {\n return uploadToBrowserStack(localPath, opts);\n }\n return localPath;\n }\n\n // Parse s3://bucket/key\n const withoutScheme = appS3Path.replace(/^s3:\\/\\//, '');\n const slashIdx = withoutScheme.indexOf('/');\n if (slashIdx === -1) {\n throw new Error(`Invalid S3 path: ${appS3Path}. Expected format: s3://bucket/path/to/file`);\n }\n const bucket = withoutScheme.slice(0, slashIdx);\n const key = withoutScheme.slice(slashIdx + 1);\n const extMap: Record<string, string> = { android: '.apk', ios: '.ipa', windows: '.exe', macos: '.app' };\n const ext = extMap[platform] ?? '';\n const tmpPath = path.join(os.tmpdir(), `zeta-native-${Date.now()}${ext}`);\n\n logger.info({ bucket, key, tmpPath, provider: storageCfg?.provider ?? 'env' }, 'Downloading app binary from storage');\n\n const { S3Client, GetObjectCommand } = await import('@aws-sdk/client-s3' as any);\n const isR2 = storageCfg?.provider === 'R2';\n const s3ClientCfg: Record<string, any> = {\n region: isR2 ? 'auto' : (storageCfg?.region ?? process.env.AWS_REGION ?? 'us-east-1'),\n };\n if (storageCfg?.accessKeyId && storageCfg?.secretAccessKey) {\n s3ClientCfg.credentials = { accessKeyId: storageCfg.accessKeyId, secretAccessKey: storageCfg.secretAccessKey };\n }\n if (isR2 && storageCfg?.accountId) {\n s3ClientCfg.endpoint = `https://${storageCfg.accountId}.r2.cloudflarestorage.com`;\n s3ClientCfg.forcePathStyle = true;\n }\n const s3 = new S3Client(s3ClientCfg);\n const response = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));\n\n // Stream the body to disk\n const { Readable } = await import('node:stream');\n const { pipeline } = await import('node:stream/promises');\n const fh = await fs.open(tmpPath, 'w');\n try {\n await pipeline(Readable.from(response.Body as any), fh.createWriteStream());\n } finally {\n await fh.close();\n }\n\n logger.info({ tmpPath }, 'App binary downloaded');\n\n // For BrowserStack, upload the binary and return the bs:// URL\n if (opts.deviceFarm === 'browserstack') {\n return uploadToBrowserStack(tmpPath, opts);\n }\n return tmpPath;\n}\n\n/**\n * Parse the Appium XML page source into a flat list of NativeElement records.\n *\n * Android UiAutomator2 XML looks like:\n * <node text=\"Login\" resource-id=\"com.example:id/btn_login\"\n * class=\"android.widget.Button\" bounds=\"[100,400][980,520]\"\n * clickable=\"true\" enabled=\"true\" />\n *\n * iOS XCUITest XML looks like:\n * <XCUIElementTypeButton name=\"Login\" label=\"Login\"\n * x=\"50\" y=\"300\" width=\"280\" height=\"44\"\n * enabled=\"true\" visible=\"true\" />\n */\nfunction parseAccessibilityTree(xmlSource: string, platform: 'android' | 'ios' | 'windows' | 'macos'): NativeElement[] {\n const elements: NativeElement[] = [];\n\n if (platform === 'windows') {\n // WinAppDriver returns UIAutomation XML like:\n // <Button Name=\"OK\" AutomationId=\"OkButton\" IsEnabled=\"true\" IsOffscreen=\"false\"/>\n const WIN_INTERACTIVE = new Set(['Button', 'Edit', 'ComboBox', 'CheckBox', 'RadioButton', 'Slider', 'ListItem', 'MenuItem', 'Hyperlink']);\n const nodeRegex = /<(\\w+)\\s([^>]*)\\/>/g;\n let match: RegExpExecArray | null;\n while ((match = nodeRegex.exec(xmlSource)) !== null) {\n const typeName = match[1] ?? '';\n if (!WIN_INTERACTIVE.has(typeName)) continue;\n const attrs = match[2] ?? '';\n const get = (name: string) => new RegExp(`\\\\b${name}=\"([^\"]*)\"`, 'i').exec(attrs)?.[1] ?? '';\n if (get('IsEnabled') === 'false' || get('IsOffscreen') === 'true') continue;\n const label = get('Name') || get('AutomationId') || '';\n if (!label) continue;\n const role = winTypeToRole(typeName);\n // BoundingRectangle=\"x:0 y:0 width:100 height:30\" or similar\n const brStr = get('BoundingRectangle');\n const brMatch = /x:(\\d+)\\s+y:(\\d+)\\s+width:(\\d+)\\s+height:(\\d+)/i.exec(brStr);\n const bounds = brMatch\n ? { x: parseInt(brMatch[1]), y: parseInt(brMatch[2]), width: parseInt(brMatch[3]), height: parseInt(brMatch[4]) }\n : { x: 0, y: 0, width: 100, height: 30 };\n elements.push({ role, label, bounds, platform: 'windows' as any, className: typeName });\n }\n }\n\n if (platform === 'macos') {\n const MAC_INTERACTIVE = new Set([\n 'XCUIElementTypeButton', 'XCUIElementTypeTextField', 'XCUIElementTypeSecureTextField',\n 'XCUIElementTypeCheckBox', 'XCUIElementTypeRadioButton', 'XCUIElementTypePopUpButton',\n 'XCUIElementTypeComboBox', 'XCUIElementTypeSlider', 'XCUIElementTypeMenuItem',\n 'XCUIElementTypeSearchField', 'XCUIElementTypeLink',\n ]);\n const nodeRegex = /<(XCUIElementType\\w+)\\s([^>]*)\\/>/g;\n let match: RegExpExecArray | null;\n while ((match = nodeRegex.exec(xmlSource)) !== null) {\n const typeName = match[1] ?? '';\n if (!MAC_INTERACTIVE.has(typeName)) continue;\n const attrs = match[2] ?? '';\n const get = (name: string) => new RegExp(`\\\\b${name}=\"([^\"]*)\"`, 'i').exec(attrs)?.[1] ?? '';\n if (get('enabled') === 'false') continue;\n const label = get('title') || get('label') || get('value') || '';\n if (!label) continue;\n const x = parseFloat(get('x') || '0');\n const y = parseFloat(get('y') || '0');\n const width = parseFloat(get('width') || '0');\n const height = parseFloat(get('height') || '0');\n const role = macTypeToRole(typeName);\n elements.push({ role, label, bounds: { x, y, width, height }, platform: 'macos' as any, className: typeName });\n }\n }\n\n if (platform === 'android') {\n // Match interactive node elements\n const INTERACTIVE_CLASSES = new Set([\n 'android.widget.Button',\n 'android.widget.EditText',\n 'android.widget.CheckBox',\n 'android.widget.RadioButton',\n 'android.widget.Switch',\n 'android.widget.Spinner',\n 'android.widget.TextView', // tappable text\n 'android.widget.ImageButton',\n 'android.widget.ToggleButton',\n 'androidx.recyclerview.widget.RecyclerView',\n 'com.google.android.material.bottomnavigation.BottomNavigationItemView',\n 'android.widget.ImageView', // tappable images (nav icons)\n ]);\n\n // Simple regex-based node extraction \u2014 avoids adding an XML parser dependency.\n // This is intentionally minimal; a full implementation would use a proper XML parser.\n const nodeRegex = /<node\\s([^>]*)\\/?>/g;\n let match: RegExpExecArray | null;\n while ((match = nodeRegex.exec(xmlSource)) !== null) {\n const attrs = match[1] ?? '';\n const get = (name: string): string => {\n const m = new RegExp(`\\\\b${name}=\"([^\"]*)\"`, 'i').exec(attrs);\n return m ? m[1] ?? '' : '';\n };\n\n const className = get('class');\n const clickable = get('clickable') === 'true';\n const enabled = get('enabled') === 'true';\n\n if (!enabled) continue;\n if (!clickable && !INTERACTIVE_CLASSES.has(className)) continue;\n\n const label = get('content-desc') || get('text') || get('resource-id').split('/').pop() || '';\n if (!label) continue;\n\n const resourceId = get('resource-id');\n const boundsStr = get('bounds'); // \"[x1,y1][x2,y2]\"\n const bounds = parseAndroidBounds(boundsStr);\n if (!bounds) continue;\n\n const role = classToRole(className);\n elements.push({ role, label, bounds, platform: 'android', resourceId, className });\n }\n } else {\n // iOS \u2014 XCUITest element types\n const INTERACTIVE_TYPES = new Set([\n 'XCUIElementTypeButton',\n 'XCUIElementTypeTextField',\n 'XCUIElementTypeSecureTextField',\n 'XCUIElementTypeSwitch',\n 'XCUIElementTypeSlider',\n 'XCUIElementTypeCheckBox',\n 'XCUIElementTypeRadioButton',\n 'XCUIElementTypePickerWheel',\n 'XCUIElementTypeSearchField',\n 'XCUIElementTypeCell',\n 'XCUIElementTypeLink',\n 'XCUIElementTypeTabBarItem',\n 'XCUIElementTypeNavigationBar',\n ]);\n\n const nodeRegex = /<(XCUIElementType\\w+)\\s([^>]*)\\/>/g;\n let match: RegExpExecArray | null;\n while ((match = nodeRegex.exec(xmlSource)) !== null) {\n const typeName = match[1] ?? '';\n if (!INTERACTIVE_TYPES.has(typeName)) continue;\n\n const attrs = match[2] ?? '';\n const get = (name: string): string => {\n const m = new RegExp(`\\\\b${name}=\"([^\"]*)\"`, 'i').exec(attrs);\n return m ? m[1] ?? '' : '';\n };\n\n if (get('enabled') === 'false') continue;\n\n const label = get('label') || get('name') || get('value') || '';\n if (!label) continue;\n\n const x = parseFloat(get('x') || '0');\n const y = parseFloat(get('y') || '0');\n const width = parseFloat(get('width') || '0');\n const height = parseFloat(get('height') || '0');\n if (width === 0 && height === 0) continue;\n\n const role = xcuiTypeToRole(typeName);\n elements.push({ role, label, bounds: { x, y, width, height }, platform: 'ios', className: typeName });\n }\n }\n\n return elements;\n}\n\n/** Parse Android bounds string \"[x1,y1][x2,y2]\" \u2192 NativeBounds */\nfunction parseAndroidBounds(boundsStr: string): NativeBounds | null {\n const m = /\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]/.exec(boundsStr);\n if (!m) return null;\n const x1 = parseInt(m[1] ?? '0', 10);\n const y1 = parseInt(m[2] ?? '0', 10);\n const x2 = parseInt(m[3] ?? '0', 10);\n const y2 = parseInt(m[4] ?? '0', 10);\n return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };\n}\n\n/** Map Android widget class names to ZeTa element roles */\nfunction classToRole(className: string): string {\n if (className.includes('Button')) return 'button';\n if (className.includes('EditText')) return 'input';\n if (className.includes('CheckBox')) return 'checkbox';\n if (className.includes('RadioButton')) return 'radio';\n if (className.includes('Switch') || className.includes('Toggle')) return 'switch';\n if (className.includes('Spinner')) return 'select';\n if (className.includes('TextView')) return 'text';\n return className.split('.').pop()?.toLowerCase() ?? 'unknown';\n}\n\n/** Map XCUITest element type strings to ZeTa element roles */\nfunction xcuiTypeToRole(xcuiType: string): string {\n if (xcuiType.includes('Button')) return 'button';\n if (xcuiType.includes('TextField')) return 'input';\n if (xcuiType.includes('SecureTextField')) return 'input';\n if (xcuiType.includes('Switch')) return 'switch';\n if (xcuiType.includes('Slider')) return 'slider';\n if (xcuiType.includes('CheckBox')) return 'checkbox';\n if (xcuiType.includes('RadioButton')) return 'radio';\n if (xcuiType.includes('PickerWheel')) return 'select';\n if (xcuiType.includes('SearchField')) return 'input';\n return xcuiType.replace('XCUIElementType', '').toLowerCase();\n}\n\n/** Map WinAppDriver UIAutomation element type names to ZeTa element roles */\nfunction winTypeToRole(winType: string): string {\n if (winType === 'Button') return 'button';\n if (winType === 'Edit') return 'input';\n if (winType === 'ComboBox') return 'select';\n if (winType === 'CheckBox') return 'checkbox';\n if (winType === 'RadioButton') return 'radio';\n if (winType === 'Slider') return 'slider';\n if (winType === 'ListItem') return 'option';\n if (winType === 'MenuItem') return 'menuitem';\n if (winType === 'Hyperlink') return 'link';\n return winType.toLowerCase();\n}\n\n/** Map Mac2Driver XCUIElementType strings to ZeTa element roles */\nfunction macTypeToRole(xcuiType: string): string {\n if (xcuiType.includes('Button')) return 'button';\n if (xcuiType.includes('SecureTextField')) return 'input';\n if (xcuiType.includes('TextField')) return 'input';\n if (xcuiType.includes('CheckBox')) return 'checkbox';\n if (xcuiType.includes('RadioButton')) return 'radio';\n if (xcuiType.includes('PopUpButton')) return 'select';\n if (xcuiType.includes('ComboBox')) return 'select';\n if (xcuiType.includes('Slider')) return 'slider';\n if (xcuiType.includes('MenuItem')) return 'menuitem';\n if (xcuiType.includes('SearchField')) return 'input';\n if (xcuiType.includes('Link')) return 'link';\n return xcuiType.replace('XCUIElementType', '').toLowerCase();\n}\n\n// \u2500\u2500\u2500 Gap 1: Native Auth \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Detect if the current screen is a login/auth screen and fill credentials. */\nasync function attemptNativeLogin(\n driver: any,\n platform: 'android' | 'ios' | 'windows' | 'macos',\n credentials: NonNullable<NativeCrawlerOptions['credentials']>,\n): Promise<boolean> {\n try {\n const pageXml: string = await driver.getPageSource();\n const elements = parseAccessibilityTree(pageXml, platform);\n\n const inputEls = elements.filter((e) => e.role === 'input');\n if (inputEls.length === 0) return false;\n\n // Detect login screen heuristic: has at least one text input and one\n // \"password\" input (SecureTextField on iOS, password type on Android)\n const isPasswordField = (e: NativeElement) =>\n e.className?.toLowerCase().includes('secure') ||\n e.className?.toLowerCase().includes('password') ||\n e.label?.toLowerCase().match(/password|passcode|pin|secret/i) != null;\n\n const hasPasswordInput = elements.some(isPasswordField);\n if (!hasPasswordInput && inputEls.length < 2) return false;\n\n logger.info({ platform }, 'Native auth: login screen detected \u2014 filling credentials');\n\n // Fill username field: first non-password input\n const unLabel = credentials.usernameFieldLabel?.toLowerCase() ?? '';\n const usernameEl = inputEls.find((e) => {\n if (isPasswordField(e)) return false;\n if (unLabel) return e.label?.toLowerCase().includes(unLabel);\n return e.label?.toLowerCase().match(/email|username|user|login|phone|mobile/i) != null || true;\n }) ?? inputEls[0];\n\n if (usernameEl && credentials.username) {\n const uSelector = usernameEl.resourceId\n ? `id=${usernameEl.resourceId}`\n : `~${usernameEl.label}`;\n try {\n const uEl = await driver.$(uSelector);\n await uEl.click();\n await driver.pause(300);\n await uEl.clearValue();\n await uEl.setValue(credentials.username);\n } catch { /* non-fatal */ }\n }\n\n // Fill password field\n const pwEl = elements.find(isPasswordField);\n if (pwEl && credentials.password) {\n const pSelector = pwEl.resourceId ? `id=${pwEl.resourceId}` : `~${pwEl.label}`;\n try {\n const pEl = await driver.$(pSelector);\n await pEl.click();\n await driver.pause(300);\n await pEl.clearValue();\n await pEl.setValue(credentials.password);\n } catch { /* non-fatal */ }\n }\n\n // Dismiss keyboard then tap login button\n try { await driver.hideKeyboard(); } catch { /* not always available */ }\n await driver.pause(500);\n\n const loginLabel = credentials.loginButtonLabel ?? '';\n const loginBtn = elements.find((e) =>\n e.role === 'button' &&\n (loginLabel\n ? e.label?.toLowerCase().includes(loginLabel.toLowerCase())\n : e.label?.toLowerCase().match(/log\\s?in|sign\\s?in|submit|continue|next|connect/i) != null)\n );\n if (loginBtn) {\n const btnSelector = loginBtn.resourceId ? `id=${loginBtn.resourceId}` : `~${loginBtn.label}`;\n try {\n await driver.$(btnSelector).click();\n await driver.pause(3000); // wait for auth response\n logger.info({ platform }, 'Native auth: login button tapped \u2014 waiting for app');\n return true;\n } catch { /* non-fatal */ }\n }\n return false;\n } catch (err) {\n logger.warn({ err }, 'Native auth attempt failed \u2014 continuing without login');\n return false;\n }\n}\n\n// \u2500\u2500\u2500 Gap 3: Gesture Navigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Perform a swipe gesture on the screen. */\nasync function performSwipe(\n driver: any,\n platform: 'android' | 'ios' | 'windows' | 'macos',\n direction: 'left' | 'right' | 'up' | 'down',\n): Promise<void> {\n try {\n const size: { width: number; height: number } = await driver.getWindowSize();\n const { width: W, height: H } = size;\n const midX = Math.round(W / 2);\n const midY = Math.round(H / 2);\n\n if (platform === 'android') {\n // Use W3C actions for Android\n const startX = direction === 'left' ? Math.round(W * 0.8) : direction === 'right' ? Math.round(W * 0.2) : midX;\n const startY = direction === 'up' ? Math.round(H * 0.8) : direction === 'down' ? Math.round(H * 0.2) : midY;\n const endX = direction === 'left' ? Math.round(W * 0.2) : direction === 'right' ? Math.round(W * 0.8) : midX;\n const endY = direction === 'up' ? Math.round(H * 0.2) : direction === 'down' ? Math.round(H * 0.8) : midY;\n await driver.touchAction([\n { action: 'press', x: startX, y: startY },\n { action: 'wait', ms: 300 },\n { action: 'moveTo', x: endX, y: endY },\n { action: 'release' },\n ]);\n } else if (platform === 'ios') {\n await driver.execute('mobile: swipe', { direction });\n } else {\n // Windows/macOS: use pointer actions\n await driver.execute('mobile: scroll', { direction });\n }\n await driver.pause(500);\n } catch { /* gesture not supported on this platform/driver \u2014 non-fatal */ }\n}\n\n/** Long-press an element by its selector. */\nasync function performLongPress(driver: any, selector: string, durationMs = 1200): Promise<void> {\n try {\n const el = await driver.$(selector);\n await driver.touchAction([\n { action: 'longPress', element: el, ms: durationMs },\n { action: 'release' },\n ]);\n await driver.pause(600);\n } catch { /* non-fatal */ }\n}\n\n// \u2500\u2500\u2500 Gap 2: Vision Fallback \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** When the accessibility tree yields zero interactive elements, use LLM vision to describe the screen. */\nasync function visionFallbackDescribeScreen(\n screenshotBase64: string,\n llmConfig: { provider: string; model: string; apiKey?: string; baseUrl?: string },\n): Promise<{ screenDescription: string; suggestedElements: Array<{ role: string; label: string }> }> {\n try {\n const Anthropic = (await import('@anthropic-ai/sdk')).default;\n if (!llmConfig.apiKey) throw new Error('No LLM API key configured. Configure an Anthropic key in Workspace \u2192 Settings \u2192 AI Provider.');\n const client = new Anthropic({ apiKey: llmConfig.apiKey });\n const response = await client.messages.create({\n model: llmConfig.model ?? 'claude-haiku-4-5-20251001',\n max_tokens: 512,\n messages: [{\n role: 'user',\n content: [\n { type: 'image', source: { type: 'base64', media_type: 'image/png', data: screenshotBase64 } },\n { type: 'text', text: 'This is a screenshot from a mobile app. The accessibility tree returned no elements. Describe what you see on screen in 1-2 sentences, then list ALL interactive UI elements (buttons, inputs, links, toggles) as JSON: [{\"role\":\"button\",\"label\":\"Sign In\"},...]' },\n ],\n }],\n });\n const text = (response.content[0] as any)?.text ?? '';\n const jsonMatch = text.match(/\\[[\\s\\S]*\\]/);\n const elements = jsonMatch ? JSON.parse(jsonMatch[0]) : [];\n return { screenDescription: text.split('[')[0]?.trim() ?? '', suggestedElements: elements };\n } catch (err) {\n logger.warn({ err }, 'Vision fallback LLM call failed');\n }\n return { screenDescription: 'Screen captured (accessibility tree empty \u2014 vision fallback unavailable)', suggestedElements: [] };\n}\n\n// \u2500\u2500\u2500 Gap 4: MITM Proxy API Capture \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Start a lightweight proxy server to intercept native app API traffic. */\nasync function startMitmProxy(port: number): Promise<{ captured: Array<{ method: string; url: string; status?: number }>; stop: () => void }> {\n const captured: Array<{ method: string; url: string; status?: number }> = [];\n try {\n const net = await import('net');\n // Simple TCP intercept marker \u2014 full MITM requires SSL cert installation on device.\n // This records connection attempts; full response interception requires device trust cert.\n const server = net.createServer((socket) => {\n socket.once('data', (data) => {\n const raw = data.toString('utf8', 0, 512);\n const match = /^(GET|POST|PUT|DELETE|PATCH|HEAD)\\s+(https?:\\/\\/[^\\s]+)/i.exec(raw);\n if (match) captured.push({ method: match[1]!, url: match[2]! });\n socket.destroy();\n });\n });\n server.listen(port, '127.0.0.1');\n const stop = () => server.close();\n logger.info({ port }, 'MITM proxy listener started (connection-level only \u2014 install device trust cert for full interception)');\n return { captured, stop };\n } catch (err) {\n logger.warn({ err }, 'MITM proxy start failed \u2014 API capture disabled');\n return { captured, stop: () => {} };\n }\n}\n\n/** Update the PlatformJob status/progress in DB \u2014 non-fatal. */\nasync function updateProgress(tenantId: string, jobId: string, progress: number, step: string): Promise<void> {\n try {\n await withTenant(tenantId, (tx) =>\n tx.platformJob.update({\n where: { id: jobId },\n data: { progress, result: { step } as any },\n })\n );\n } catch (err) {\n logger.warn({ err }, 'Failed to update job progress (non-fatal)');\n }\n}\n\n// ---------------------------------------------------------------------------\n// Main export\n// ---------------------------------------------------------------------------\n\n/**\n * Run a native app crawl session via Appium.\n *\n * Flow:\n * 1. Download APK/IPA from S3 to local temp dir\n * 2. Build Appium WebDriverIO capabilities for the target platform\n * 3. Create WebDriverIO remote session (Appium server at APPIUM_SERVER_URL)\n * 4. For each discovered screen:\n * a. Get page source XML \u2192 parse elements\n * b. Take screenshot \u2192 save to screenshotDir \u2192 persist via AppBrain\n * c. Save screen + elements to DB\n * d. Tap nav elements to discover next screens\n * 5. Update PlatformJob as DONE (or FAILED on error)\n */\nexport async function runNativeCrawl(opts: NativeCrawlerOptions): Promise<void> {\n const { tenantId, projectId, jobId, platform } = opts;\n const screenshotDir = path.join(config.screenshotDir, projectId);\n await fs.mkdir(screenshotDir, { recursive: true });\n\n let tmpAppPath: string | null = null;\n\n // Mark job RUNNING\n await withTenant(tenantId, (tx) =>\n tx.platformJob.update({\n where: { id: jobId },\n data: { status: 'RUNNING', startedAt: new Date() },\n })\n );\n\n const tenantStorageCfg = await getTenantStorageConfig(tenantId).catch(() => null);\n\n try {\n // \u2500\u2500 Step 1: Download app binary from S3 / R2 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n await updateProgress(tenantId, jobId, 5, 'Downloading app binary');\n tmpAppPath = await downloadAppFromS3(opts.appS3Path, platform, opts, tenantStorageCfg);\n\n // \u2500\u2500 Step 2: Build Appium capabilities \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n await updateProgress(tenantId, jobId, 10, 'Building Appium session capabilities');\n const capabilities = buildCapabilities(opts, tmpAppPath);\n\n // \u2500\u2500 Step 3: Start WebDriverIO session \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // TODO: add \"webdriverio\": \"^9.x\" to packages/crawler/package.json\n // TODO: requires Appium server running at APPIUM_SERVER_URL env var\n await updateProgress(tenantId, jobId, 15, 'Starting Appium session');\n\n const appiumServerUrl = opts.appiumServerUrl ?? process.env.APPIUM_SERVER_URL ?? 'http://127.0.0.1:4723';\n\n // Pre-check connectivity before wdio throws an opaque EAI_AGAIN\n try {\n const probe = await fetch(`${appiumServerUrl}/status`, { signal: AbortSignal.timeout(5000) });\n if (!probe.ok) throw new Error(`HTTP ${probe.status}`);\n } catch (e: any) {\n throw new Error(\n `Appium server at ${appiumServerUrl} is not reachable (${e.message}). ` +\n `Start it with: cd /opt/zeta-ai && docker compose -f docker-compose.prod.yml -f docker-compose.appium.yml up -d appium`\n );\n }\n\n // TODO: add \"webdriverio\": \"^9.x\" to packages/crawler/package.json before deploying.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const wdio: any = await import('webdriverio' as any);\n const driver = await wdio.remote({\n hostname: new URL(appiumServerUrl).hostname,\n port: parseInt(new URL(appiumServerUrl).port || '4723', 10),\n path: '/',\n capabilities: capabilities as any,\n logLevel: 'warn',\n });\n\n logger.info({ platform, deviceFarm: opts.deviceFarm }, 'Appium session started');\n\n try {\n // Update DB with Appium session ID\n const sessionId: string = driver.sessionId;\n await withTenant(tenantId, (tx) =>\n (tx as any).platformJob.update({\n where: { id: jobId },\n data: { appiumSessionId: sessionId },\n })\n );\n\n // \u2500\u2500 Step 4: Crawl screens \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n await updateProgress(tenantId, jobId, 20, 'App launched \u2014 extracting initial screen');\n\n // Gap 4: Start MITM proxy for API traffic capture\n let mitmStop: (() => void) | null = null;\n const mitmCaptured: Array<{ method: string; url: string; status?: number }> = [];\n if (opts.mitmProxyPort) {\n const mitm = await startMitmProxy(opts.mitmProxyPort);\n mitmStop = mitm.stop;\n mitmCaptured.push(...mitm.captured);\n }\n\n // Allow the app to reach its initial state\n await driver.pause(2000);\n\n // Gap 1: Attempt native login before BFS begins\n if (opts.credentials?.username && opts.credentials?.password) {\n const loggedIn = await attemptNativeLogin(driver, platform, opts.credentials);\n if (loggedIn) await driver.pause(2500); // wait for post-login navigation\n }\n\n // Capture launch screen then navigate forward via discoverable nav elements.\n const MAX_SCREENS = 30;\n let screensFound = 0;\n const tappedSelectors = new Set<string>();\n\n for (let i = 0; i < MAX_SCREENS; i++) {\n const pct = 20 + Math.round((i / MAX_SCREENS) * 60);\n await updateProgress(tenantId, jobId, pct, `Capturing screen ${i + 1}`);\n\n // a. Get screen title / name heuristic\n let screenName = `Screen ${i + 1}`;\n try {\n // Android: use current activity name; iOS: use app title from accessibility tree\n // Windows/macOS: use driver.getTitle() for the window title\n if (platform === 'android') {\n const activity: string = await (driver as any).getCurrentActivity();\n screenName = activity.split('.').pop() ?? screenName;\n } else if (platform === 'ios') {\n // iOS: attempt to read the navigation bar title\n const titleEl = await driver.$('~NavigationBarTitle').catch(() => null);\n if (titleEl) {\n const titleText = await (titleEl as any).getText().catch(() => '');\n if (titleText) screenName = titleText;\n }\n } else {\n // windows / macos: use window title\n const winTitle: string = await driver.getTitle().catch(() => '');\n if (winTitle) screenName = winTitle;\n }\n } catch { /* non-fatal \u2014 use default name */ }\n\n // b. Take screenshot\n const screenshotBase64: string = await driver.takeScreenshot();\n const screenshotBuffer = Buffer.from(screenshotBase64, 'base64');\n\n // DPR normalization: resize physical pixels \u2192 logical pixels\n let screenshotBuf = screenshotBuffer;\n try {\n const sharp = (await import('sharp')).default;\n const meta = await sharp(screenshotBuffer).metadata();\n const logicalSize: { width: number; height: number } = await (driver as any).getWindowSize().catch(() => ({ width: meta.width ?? 0, height: meta.height ?? 0 }));\n const physW = meta.width ?? 0;\n const logW = logicalSize.width;\n if (physW > 0 && logW > 0 && physW > logW) {\n const logH = Math.round((meta.height ?? physW) * (logW / physW));\n screenshotBuf = Buffer.from(await sharp(screenshotBuffer).resize(logW, logH, { fit: 'fill' }).png().toBuffer());\n }\n } catch { /* sharp unavailable or resize failed \u2014 use raw buffer */ }\n\n // Save screen record first (to get screen.id)\n const screen = await AppBrain.saveScreen(tenantId, projectId, screenName);\n const shotPath = path.join(screenshotDir, `${screen.id}-native-v${screen.version}.png`);\n await fs.writeFile(shotPath, screenshotBuf);\n let cloudUrl: string | undefined;\n if (tenantStorageCfg) {\n const s3Key = `${tenantId}/${projectId}/screenshots/${screen.id}-native-v${screen.version}.png`;\n cloudUrl = await uploadScreenshotToTenantStorage(tenantStorageCfg, shotPath, s3Key).catch(() => undefined);\n }\n await AppBrain.saveScreenshot(tenantId, projectId, screen.id, shotPath, screen.version, 'NATIVE', cloudUrl);\n\n // c. Parse accessibility tree \u2192 save elements\n // Scroll down to reveal below-fold elements, then scroll back up before parsing\n try {\n if (platform === 'android') {\n await driver.execute('mobile: scroll', { direction: 'down' });\n } else if (platform === 'ios') {\n await driver.execute('mobile: scroll', { direction: 'down', element: null });\n }\n await driver.pause(400);\n } catch { /* scroll not always available \u2014 non-fatal */ }\n\n const pageXml: string = await driver.getPageSource();\n const nativeElements = parseAccessibilityTree(pageXml, platform);\n try {\n if (platform === 'android') {\n await driver.execute('mobile: scroll', { direction: 'up' });\n } else if (platform === 'ios') {\n await driver.execute('mobile: scroll', { direction: 'up', element: null });\n }\n await driver.pause(300);\n } catch { /* non-fatal */ }\n logger.info({ screenName, elements: nativeElements.length }, 'Native screen parsed');\n\n // Gap 2: Vision fallback when accessibility tree is empty (Flutter / game engines)\n if (nativeElements.length === 0 && opts.visionLlmConfig) {\n logger.info({ screenName }, 'Empty accessibility tree \u2014 triggering vision LLM fallback');\n const { suggestedElements } = await visionFallbackDescribeScreen(screenshotBase64, opts.visionLlmConfig);\n for (const el of suggestedElements) {\n nativeElements.push({\n role: el.role, label: el.label, resourceId: '', className: '',\n bounds: { x: 0, y: 0, width: 0, height: 0 }, platform,\n });\n }\n }\n\n for (const el of nativeElements) {\n const boundingRect = {\n x: Math.round(el.bounds.x),\n y: Math.round(el.bounds.y),\n width: Math.round(el.bounds.width),\n height: Math.round(el.bounds.height),\n };\n const notes = JSON.stringify({\n ...(el.resourceId ? { resourceId: el.resourceId } : {}),\n ...(el.className ? { className: el.className } : {}),\n platform: el.platform,\n });\n await AppBrain.saveElement(\n tenantId,\n projectId,\n screen.id,\n el.label, // meaning\n el.role,\n undefined, // expectedData\n notes,\n undefined, // text\n true, // isInteractive\n boundingRect,\n undefined, // ariaState\n undefined, // parentLandmark\n 'NATIVE', // viewport\n );\n }\n\n screensFound++;\n\n // d. Navigate to next screen \u2014 basic BFS via nav-like elements\n const navElements = nativeElements.filter(el =>\n ['button', 'text'].includes(el.role) ||\n el.label?.match(/menu|home|back|next|continue|tab|nav|dashboard|settings|profile|more|list|search|help|about/i)\n );\n\n for (const navEl of navElements.slice(0, 15)) {\n if (screensFound >= MAX_SCREENS) break;\n try {\n // Build a selector for this element\n let selector: string;\n if (platform === 'android' && navEl.resourceId) {\n selector = `id=${navEl.resourceId}`;\n } else if (navEl.label) {\n selector = `~${navEl.label}`;\n } else {\n continue;\n }\n\n if (tappedSelectors.has(selector)) continue;\n tappedSelectors.add(selector);\n\n // Capture pre-tap screen identity\n let preActivity = '';\n try {\n if (platform === 'android') {\n preActivity = await (driver as any).getCurrentActivity();\n } else {\n preActivity = await driver.getTitle();\n }\n } catch { /* non-fatal */ }\n\n await driver.$(selector).click();\n await driver.pause(1500);\n\n // Android crash detection via logcat\n if (platform === 'android') {\n try {\n const logs: any[] = await (driver as any).getLogs('logcat');\n const crashLog = logs.find((l: any) =>\n typeof l.message === 'string' && /FATAL EXCEPTION|Process.*has died|ANR in/i.test(l.message)\n );\n if (crashLog) {\n logger.error({ jobId, crash: String(crashLog.message).slice(0, 500) }, 'App crash detected during crawl');\n break; // stop navigating from this screen\n }\n } catch { /* logcat not available on all environments \u2014 non-fatal */ }\n }\n // iOS crash log detection\n if (platform === 'ios') {\n const crashLogs: any[] = await (driver as any).getLogs('crashlog').catch(() => []);\n if (crashLogs.length > 0 && crashLogs.some((l: any) => l.message?.includes('Exception') || l.message?.includes('crash'))) {\n logger.warn({ screenName }, 'iOS crash log detected \u2014 stopping crawl');\n break;\n }\n }\n\n // Check if screen changed\n let postActivity = '';\n try {\n if (platform === 'android') {\n postActivity = await (driver as any).getCurrentActivity();\n } else {\n postActivity = await driver.getTitle();\n }\n } catch { /* non-fatal */ }\n\n if (postActivity === preActivity) continue; // same screen \u2014 skip\n\n // Check MAX_SCREENS before saving the new screen\n if (screensFound >= MAX_SCREENS) break;\n\n // New screen \u2014 capture it\n const newScreenName = postActivity\n ? postActivity.split('.').pop() ?? `Screen ${screensFound + 1}`\n : `Screen ${screensFound + 1}`;\n const newScreenshot: string = await driver.takeScreenshot();\n const newScreen = await AppBrain.saveScreen(tenantId, projectId, newScreenName);\n const newShotPath = path.join(screenshotDir, `${newScreen.id}-native-v${newScreen.version}.png`);\n await fs.writeFile(newShotPath, Buffer.from(newScreenshot, 'base64'));\n let newCloudUrl: string | undefined;\n if (tenantStorageCfg) {\n const s3Key = `${tenantId}/${projectId}/screenshots/${newScreen.id}-native-v${newScreen.version}.png`;\n newCloudUrl = await uploadScreenshotToTenantStorage(tenantStorageCfg, newShotPath, s3Key).catch(() => undefined);\n }\n await AppBrain.saveScreenshot(tenantId, projectId, newScreen.id, newShotPath, newScreen.version, 'NATIVE', newCloudUrl);\n\n const newXml: string = await driver.getPageSource();\n const newElements = parseAccessibilityTree(newXml, platform);\n for (const el of newElements) {\n await AppBrain.saveElement(\n tenantId, projectId, newScreen.id,\n el.label, el.role, undefined,\n JSON.stringify({ ...(el.resourceId ? { resourceId: el.resourceId } : {}), ...(el.className ? { className: el.className } : {}), platform: el.platform }),\n undefined, true,\n { x: Math.round(el.bounds.x), y: Math.round(el.bounds.y), width: Math.round(el.bounds.width), height: Math.round(el.bounds.height) },\n undefined, undefined, 'NATIVE',\n );\n }\n screensFound++;\n\n // Navigate back so subsequent nav attempts start from a known state\n try { await driver.back(); await driver.pause(800); } catch { /* non-fatal */ }\n } catch { /* element may have disappeared or tap failed \u2014 skip */ }\n }\n\n // Gap 3: Gesture-based discovery \u2014 swipe to reveal off-screen content\n if (opts.gestureConfig?.enableSwipe && screensFound < MAX_SCREENS) {\n for (const dir of ['left', 'right', 'up', 'down'] as const) {\n await performSwipe(driver, platform, dir);\n const postSwipeXml = await driver.getPageSource().catch(() => '');\n if (postSwipeXml && postSwipeXml !== pageXml) break; // new content found\n }\n }\n\n // Nav pass complete for this screen \u2014 continue BFS to next iteration.\n }\n\n // \u2500\u2500 Step 5: Mark job DONE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n if (mitmStop) mitmStop();\n await driver.deleteSession();\n await updateProgress(tenantId, jobId, 99, `Crawl complete \u2014 ${screensFound} screen(s) captured`);\n await withTenant(tenantId, (tx) =>\n tx.platformJob.update({\n where: { id: jobId },\n data: {\n status: 'DONE',\n progress: 100,\n finishedAt: new Date(),\n result: { screensFound, platform } as any,\n },\n })\n );\n logger.info({ jobId, screensFound, platform }, 'Native crawl complete');\n\n } catch (sessionErr) {\n // Best-effort: clean up Appium session before re-throwing\n try { await driver.deleteSession(); } catch { /* ignore */ }\n throw sessionErr;\n }\n\n } catch (err: any) {\n logger.error({ jobId, err: String(err) }, 'Native crawl failed');\n await withTenant(tenantId, (tx) =>\n tx.platformJob.update({\n where: { id: jobId },\n data: {\n status: 'FAILED',\n error: String(err?.message ?? err),\n finishedAt: new Date(),\n },\n })\n ).catch(() => {});\n throw err;\n } finally {\n // Clean up local temp file\n if (tmpAppPath) {\n fs.unlink(tmpAppPath).catch(() => {});\n }\n }\n}\n"],
|
|
5
5
|
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/sitemap.js
CHANGED
|
@@ -89,7 +89,7 @@ async function discoverSitemapUrls(appUrl, sitemapUrl) {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
function buildProxyDispatcher(proxyUrl) {
|
|
92
|
-
if (!proxyUrl || proxyUrl === "auto") return void 0;
|
|
92
|
+
if (!proxyUrl || proxyUrl === "auto" || proxyUrl === "agent") return void 0;
|
|
93
93
|
try {
|
|
94
94
|
const m = proxyUrl.match(/^(https?):\/\/(?:([^:@]*)(?::([^@]*))?@)?(.+)$/);
|
|
95
95
|
if (!m) return new ProxyAgent(proxyUrl);
|
package/dist/sitemap.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/sitemap.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Sitemap.xml discovery \u2014 seeds the crawl queue from a project's sitemap\n * instead of (or alongside) plain link-following, matching crawl4AI's\n * sitemap-seeding capability. Best-effort throughout: most sites don't have\n * a sitemap, or it 404s, or it's malformed \u2014 none of that should ever fail\n * a crawl, it just means sitemap seeding contributes nothing this run.\n */\nimport { XMLParser } from 'fast-xml-parser';\nimport { ProxyAgent } from 'undici';\nimport { assertCrawlUrlSafe } from '@
|
|
5
|
-
"mappings": "AAOA,SAAS,iBAAiB;AAC1B,SAAS,kBAAkB;AAC3B,SAAS,0BAA0B;AACnC,SAAS,cAAc;AAEvB,MAAM,mBAAmB;AACzB,MAAM,0BAA0B;AAChC,MAAM,mBAAmB;AAEzB,eAAe,oBAAqC;AAClD,QAAM,EAAE,gBAAgB,IAAI,MAAM,OAAO,
|
|
4
|
+
"sourcesContent": ["/**\n * Sitemap.xml discovery \u2014 seeds the crawl queue from a project's sitemap\n * instead of (or alongside) plain link-following, matching crawl4AI's\n * sitemap-seeding capability. Best-effort throughout: most sites don't have\n * a sitemap, or it 404s, or it's malformed \u2014 none of that should ever fail\n * a crawl, it just means sitemap seeding contributes nothing this run.\n */\nimport { XMLParser } from 'fast-xml-parser';\nimport { ProxyAgent } from 'undici';\nimport { assertCrawlUrlSafe } from '@taodigital/core/dist/ssrf-guard.js';\nimport { logger } from './logger.js';\n\nconst FETCH_TIMEOUT_MS = 10_000;\nconst MAX_SITEMAP_INDEX_DEPTH = 3; // sitemap index -> sub-sitemaps -> (no deeper nesting expected)\nconst MAX_SUB_SITEMAPS = 50; // bound fan-out from a single sitemap index\n\nasync function getSitemapMaxUrls(): Promise<number> {\n const { getSystemConfig } = await import('@taodigital/core');\n const cfg = await getSystemConfig('crawl_tuning');\n return parseInt(String(cfg?.sitemapMaxUrls ?? '5000'), 10);\n}\n\nfunction toArray<T>(x: T | T[] | undefined | null): T[] {\n if (x == null) return [];\n return Array.isArray(x) ? x : [x];\n}\n\n/** Registrable-host key: lowercased hostname with a leading `www.` stripped. */\nfunction hostKey(hostname: string): string {\n return hostname.replace(/^www\\./i, '').toLowerCase();\n}\n\n/**\n * Same-site check that tolerates www/non-www and http/https differences \u2014 a sitemap on\n * `vercel.com` is valid for a project pointed at `www.vercel.com` (and vice versa).\n */\nexport function sameSite(candidateUrl: string, siteOrigin: string): boolean {\n try {\n const originHost = /^https?:\\/\\//i.test(siteOrigin) ? new URL(siteOrigin).hostname : siteOrigin;\n return hostKey(new URL(candidateUrl).hostname) === hostKey(originHost);\n } catch {\n return false;\n }\n}\n\n/**\n * Pulls page URLs and sub-sitemap URLs out of an already-XML-parsed sitemap\n * document (either a <urlset> or a <sitemapindex>), filtered to same-origin\n * and deduped. Pure function \u2014 no I/O \u2014 for direct unit testing without a\n * real HTTP fetch.\n */\nexport function extractUrlsFromParsedSitemap(parsed: any, origin: string, maxUrls = 5000): { pageUrls: string[]; subSitemapUrls: string[] } {\n const rawPageUrls: string[] = [];\n let subSitemapUrls: string[] = [];\n\n if (parsed?.sitemapindex) {\n subSitemapUrls = toArray(parsed.sitemapindex.sitemap)\n .map((s: any) => s?.loc)\n .filter((loc: any): loc is string => typeof loc === 'string')\n .slice(0, MAX_SUB_SITEMAPS);\n }\n\n if (parsed?.urlset) {\n rawPageUrls.push(\n ...toArray(parsed.urlset.url)\n .map((u: any) => u?.loc)\n .filter((loc: any): loc is string => typeof loc === 'string'),\n );\n }\n\n // Same-site only (www/protocol-tolerant) \u2014 a sitemap listing external URLs shouldn't seed this project's queue.\n const sameOrigin = rawPageUrls.filter((u) => sameSite(u, origin));\n\n return { pageUrls: [...new Set(sameOrigin)].slice(0, maxUrls), subSitemapUrls };\n}\n\nasync function fetchAndParseXml(url: string, dispatcher?: any): Promise<any | null> {\n // Same SSRF posture as the crawl itself (assertCrawlUrlSafe, not the\n // stricter webhook-oriented assertResolvesToPublicAddress) \u2014 a sitemap\n // living on a tenant's internal/VPN-only staging server is a legitimate\n // crawl target, not an attack; only loopback/cloud-metadata are blocked.\n try {\n assertCrawlUrlSafe(url);\n } catch {\n return null;\n }\n try {\n // Redirects followed normally here (unlike the webhook-dispatch.ts\n // 'manual' pattern) \u2014 an http\u2192https or bare\u2192www redirect on the\n // sitemap is completely ordinary, and Playwright's actual page\n // navigation later in the crawl already follows redirects to whatever\n // this same site sends it to; a sitemap fetch isn't a more sensitive\n // operation than that.\n const res = await fetch(url, {\n signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n headers: { 'User-Agent': 'ZeTa-AI-Crawler/1.0 (+sitemap-discovery)' },\n ...(dispatcher ? { dispatcher } : {}),\n });\n if (!res.ok) return null;\n const text = await res.text();\n return new XMLParser().parse(text);\n } catch {\n return null;\n }\n}\n\n/**\n * Fetches and parses a sitemap (or sitemap index, recursed up to a small\n * depth) into a flat, deduped list of same-origin URLs.\n */\nexport async function fetchSitemapUrls(sitemapUrl: string, origin: string, depth = 0, dispatcher?: any, maxUrls?: number): Promise<string[]> {\n if (depth > MAX_SITEMAP_INDEX_DEPTH) return [];\n if (maxUrls === undefined) maxUrls = await getSitemapMaxUrls();\n const parsed = await fetchAndParseXml(sitemapUrl, dispatcher);\n if (!parsed) return [];\n\n const { pageUrls, subSitemapUrls } = extractUrlsFromParsedSitemap(parsed, origin, maxUrls);\n const collected = [...pageUrls];\n\n if (subSitemapUrls.length > 0 && collected.length < maxUrls) {\n const subResults = await Promise.all(\n subSitemapUrls.slice(0, 10).map(subUrl => fetchSitemapUrls(subUrl, origin, depth + 1, dispatcher, maxUrls))\n );\n for (const sub of subResults) collected.push(...sub);\n }\n\n return [...new Set(collected)].slice(0, maxUrls);\n}\n\n/**\n * Discovers a project's sitemap (default `${origin}/sitemap.xml`, or an\n * explicit override) and returns the URLs it lists. Never throws.\n */\nexport async function discoverSitemapUrls(appUrl: string, sitemapUrl?: string): Promise<string[]> {\n let origin: string;\n try {\n origin = new URL(appUrl).origin;\n } catch {\n return [];\n }\n const target = sitemapUrl || `${origin}/sitemap.xml`;\n try {\n return await fetchSitemapUrls(target, origin);\n } catch (err) {\n logger.warn({ err: String(err), target }, '[sitemap] discovery failed, continuing without it');\n return [];\n }\n}\n\n/**\n * Build an undici ProxyAgent from a proxy URL. Parses credentials manually because\n * DataImpulse-style usernames contain commas (country codes) that break new URL().\n * Returns undefined for no/auto proxy \u2014 the caller then fetches directly.\n */\nexport function buildProxyDispatcher(proxyUrl?: string): ProxyAgent | undefined {\n if (!proxyUrl || proxyUrl === 'auto' || proxyUrl === 'agent') return undefined;\n try {\n const m = proxyUrl.match(/^(https?):\\/\\/(?:([^:@]*)(?::([^@]*))?@)?(.+)$/);\n if (!m) return new ProxyAgent(proxyUrl);\n const [, scheme, user, pass, hostPort] = m;\n const uri = `${scheme}://${hostPort}`;\n if (user) {\n const token = 'Basic ' + Buffer.from(`${decodeURIComponent(user)}:${decodeURIComponent(pass ?? '')}`).toString('base64');\n return new ProxyAgent({ uri, token });\n }\n return new ProxyAgent({ uri });\n } catch {\n return undefined;\n }\n}\n\n/** Extract same-origin `Sitemap:` directives from a robots.txt body. */\nexport function parseRobotsSitemaps(robotsTxt: string, origin: string): string[] {\n const out: string[] = [];\n for (const line of robotsTxt.split(/\\r?\\n/)) {\n const m = line.match(/^\\s*sitemap:\\s*(\\S+)/i);\n if (m && sameSite(m[1], origin)) out.push(m[1]);\n }\n return [...new Set(out)];\n}\n\nexport interface UrlCandidate {\n url: string;\n source: 'sitemap' | 'robots' | 'link';\n}\n\n/**\n * Pre-crawl URL discovery for the review-before-crawl flow: reads robots.txt Sitemap\n * directives and sitemap.xml (recursed, same-origin), proxy-aware. Returns a deduped\n * candidate list the user can review and seed before launching the Playwright crawl.\n * Never throws \u2014 bot-protected sites (Akamai/Cloudflare) simply yield an empty list.\n */\nexport async function discoverProjectUrls(\n appUrl: string,\n opts?: { sitemapUrl?: string; proxyUrl?: string },\n): Promise<{ candidates: UrlCandidate[]; sitemapFound: boolean }> {\n let origin: string;\n try { origin = new URL(appUrl).origin; } catch { return { candidates: [], sitemapFound: false }; }\n\n const dispatcher = buildProxyDispatcher(opts?.proxyUrl);\n const maxUrls = await getSitemapMaxUrls();\n\n // 1. robots.txt \u2192 declared sitemaps\n let robotsSitemaps: string[] = [];\n try {\n assertCrawlUrlSafe(`${origin}/robots.txt`);\n const res = await fetch(`${origin}/robots.txt`, {\n signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n headers: { 'User-Agent': 'ZeTa-AI-Crawler/1.0 (+url-discovery)' },\n ...(dispatcher ? { dispatcher } : {}),\n });\n if (res.ok) robotsSitemaps = parseRobotsSitemaps(await res.text(), origin);\n } catch { /* best-effort */ }\n\n // 2. sitemap targets: explicit override + robots-declared + conventional default\n const robotsSet = new Set(robotsSitemaps);\n const sitemapTargets = [...new Set([\n ...(opts?.sitemapUrl ? [opts.sitemapUrl] : []),\n ...robotsSitemaps,\n `${origin}/sitemap.xml`,\n ])];\n\n const seen = new Map<string, UrlCandidate>();\n let sitemapFound = false;\n for (const sm of sitemapTargets) {\n const urls = await fetchSitemapUrls(sm, origin, 0, dispatcher, maxUrls);\n if (urls.length) sitemapFound = true;\n const source: UrlCandidate['source'] = robotsSet.has(sm) ? 'robots' : 'sitemap';\n for (const u of urls) if (!seen.has(u)) seen.set(u, { url: u, source });\n }\n\n return { candidates: [...seen.values()].slice(0, maxUrls), sitemapFound };\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|