@detiq/crawler 1.1.8 → 1.1.9

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.
Files changed (82) hide show
  1. package/dist/accessibility-auditor.js +74 -54
  2. package/dist/ai-login.js +249 -227
  3. package/dist/auth-token-rotator.js +67 -57
  4. package/dist/auth.js +336 -299
  5. package/dist/browser-extension.js +142 -104
  6. package/dist/captcha-solver.js +125 -98
  7. package/dist/color-contrast.js +89 -77
  8. package/dist/content-chunker.js +126 -126
  9. package/dist/content-dedup.js +71 -54
  10. package/dist/cookie-consent.js +83 -68
  11. package/dist/cookie-jar.js +60 -53
  12. package/dist/crawler.js +5345 -4486
  13. package/dist/custom-js-injector.js +61 -45
  14. package/dist/dark-mode-capture.js +81 -67
  15. package/dist/dead-link-detector.js +55 -47
  16. package/dist/element-hash.js +12 -8
  17. package/dist/email-otp.js +195 -154
  18. package/dist/engine-router.js +98 -87
  19. package/dist/export/bulk-export.js +55 -70
  20. package/dist/export/vector-export.js +105 -77
  21. package/dist/extractors/advanced.js +266 -249
  22. package/dist/extractors/artifact-envelope.js +58 -59
  23. package/dist/failure-analysis-agent.js +33 -37
  24. package/dist/feature-flag-detector.js +66 -51
  25. package/dist/form-detector.js +75 -55
  26. package/dist/frontier.js +101 -76
  27. package/dist/gap-pass.js +89 -68
  28. package/dist/graphql-detector.js +72 -62
  29. package/dist/http-engine.js +330 -282
  30. package/dist/incremental-diff.js +87 -54
  31. package/dist/index.js +28 -67
  32. package/dist/input-fuzzer.js +97 -93
  33. package/dist/interactive-probe.js +827 -715
  34. package/dist/job-queue.js +2169 -2151
  35. package/dist/js-coverage.js +56 -52
  36. package/dist/js-router-detector.js +117 -86
  37. package/dist/language-detector.js +63 -51
  38. package/dist/link-classifier.js +50 -32
  39. package/dist/live-view.js +108 -91
  40. package/dist/locator-extractor.js +187 -151
  41. package/dist/logger.js +7 -9
  42. package/dist/markdown-extract.js +61 -48
  43. package/dist/native-crawler.js +946 -780
  44. package/dist/network-observer.js +156 -135
  45. package/dist/network-throttle.js +40 -29
  46. package/dist/openapi-generator.js +92 -77
  47. package/dist/pdf-extractor.js +90 -72
  48. package/dist/performance-metrics.js +102 -80
  49. package/dist/phash.js +144 -125
  50. package/dist/pii-scrubber.js +68 -65
  51. package/dist/playwright-recorder.js +58 -70
  52. package/dist/policies/crawl-safety.js +34 -31
  53. package/dist/profiles/domain-profile.js +125 -130
  54. package/dist/proxy.js +172 -146
  55. package/dist/rate-limiter.js +65 -58
  56. package/dist/redirect-tracker.js +64 -60
  57. package/dist/redis-frontier.js +136 -116
  58. package/dist/redis-queue.js +144 -157
  59. package/dist/response-cache.js +140 -127
  60. package/dist/responsive-detector.js +46 -42
  61. package/dist/retry-policy.js +50 -46
  62. package/dist/robots-txt.js +83 -69
  63. package/dist/screen-reader.js +70 -45
  64. package/dist/screenshot-masker.js +77 -59
  65. package/dist/seo-checker.js +107 -88
  66. package/dist/session-capture.js +212 -192
  67. package/dist/session-export.js +133 -107
  68. package/dist/sitemap-writer.js +41 -38
  69. package/dist/sitemap.js +202 -141
  70. package/dist/skyvern.js +148 -123
  71. package/dist/sqs-queue.js +42 -42
  72. package/dist/sso-token.js +90 -74
  73. package/dist/stealth/human-behavior.js +173 -156
  74. package/dist/telemetry.js +82 -68
  75. package/dist/test-executor.js +335 -309
  76. package/dist/tls-fingerprint.js +88 -77
  77. package/dist/url-pattern-completer.js +90 -71
  78. package/dist/visual-diff-verdict.js +38 -23
  79. package/dist/webhook-notifier.js +69 -71
  80. package/dist/websocket-detector.js +68 -56
  81. package/dist/worker.js +38 -29
  82. package/package.json +1 -1
@@ -1,839 +1,1005 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
- import os from "node:os";
4
- import { withTenant } from "@detiq/database";
5
- import { AppBrain, getTenantStorageConfig, uploadScreenshotToTenantStorage } from "@detiq/app-brain";
6
- import { config } from "@detiq/core";
7
- import { logger } from "./logger.js";
1
+ /**
2
+ * ZeTa Native App Crawler — Appium-based crawler for Android APK and iOS IPA.
3
+ *
4
+ * Architecture:
5
+ * Appium Server (running at APPIUM_SERVER_URL) → WebDriverIO session
6
+ * → UiAutomator2 (Android) / XCUITest (iOS)
7
+ * → accessibility tree XML → ZeTa Element records
8
+ *
9
+ * TODO: requires Appium server running at APPIUM_SERVER_URL env var
10
+ * TODO: requires `webdriverio` package — add "webdriverio": "^9.x" to package.json
11
+ *
12
+ * Device farm support:
13
+ * - local_emulator: Appium server + local emulator/simulator
14
+ * - aws_device_farm: presigned S3 URL uploaded to Device Farm, session via WDIO
15
+ * - browserstack: BrowserStack Automate Appium endpoint
16
+ */
17
+ import fs from 'node:fs/promises';
18
+ import path from 'node:path';
19
+ import os from 'node:os';
20
+ import { withTenant } from '@detiq/database';
21
+ import { AppBrain, getTenantStorageConfig, uploadScreenshotToTenantStorage } from '@detiq/app-brain';
22
+ import { config } from '@detiq/core';
23
+ import { logger } from './logger.js';
24
+ // ---------------------------------------------------------------------------
25
+ // Internal helpers
26
+ // ---------------------------------------------------------------------------
27
+ /**
28
+ * Build Appium desired capabilities for the given platform.
29
+ * localAppPath is the local path after downloading from S3 (or a presigned URL
30
+ * for device farms that fetch the binary themselves).
31
+ */
8
32
  function buildCapabilities(opts, localAppPath) {
9
- const appiumServerUrl = opts.appiumServerUrl ?? process.env.APPIUM_SERVER_URL ?? "http://127.0.0.1:4723";
10
- logger.info({ appiumServerUrl, platform: opts.platform }, "Building Appium capabilities");
11
- if (opts.deviceFarm === "browserstack" && (opts.platform === "android" || opts.platform === "ios")) {
12
- const bstackOptions = {
13
- userName: opts.browserstackUsername ?? process.env.BROWSERSTACK_USERNAME ?? "",
14
- accessKey: opts.browserstackAccessKey ?? process.env.BROWSERSTACK_ACCESS_KEY ?? "",
15
- deviceName: opts.deviceModel ?? (opts.platform === "android" ? "Google Pixel 7" : "iPhone 15"),
16
- osVersion: opts.osVersion ?? (opts.platform === "android" ? "13.0" : "17"),
17
- realMobile: true,
18
- projectName: "ZeTa-QIP",
19
- buildName: `${opts.platform}-crawl`,
20
- sessionName: opts.jobId
21
- };
22
- if (opts.platform === "android") {
23
- return {
24
- "platformName": "android",
25
- "appium:automationName": "UiAutomator2",
26
- "appium:app": localAppPath,
27
- "appium:newCommandTimeout": 120,
28
- "appium:autoGrantPermissions": true,
29
- ...opts.appActivity ? { "appium:appActivity": opts.appActivity } : {},
30
- ...opts.packageName ? { "appium:appPackage": opts.packageName } : {},
31
- "bstack:options": bstackOptions
32
- };
33
+ const appiumServerUrl = opts.appiumServerUrl ?? process.env.APPIUM_SERVER_URL ?? 'http://127.0.0.1:4723';
34
+ logger.info({ appiumServerUrl, platform: opts.platform }, 'Building Appium capabilities');
35
+ // BrowserStack early-return: https://hub-cloud.browserstack.com/wd/hub
36
+ if (opts.deviceFarm === 'browserstack' && (opts.platform === 'android' || opts.platform === 'ios')) {
37
+ const bstackOptions = {
38
+ userName: opts.browserstackUsername ?? process.env.BROWSERSTACK_USERNAME ?? '',
39
+ accessKey: opts.browserstackAccessKey ?? process.env.BROWSERSTACK_ACCESS_KEY ?? '',
40
+ deviceName: opts.deviceModel ?? (opts.platform === 'android' ? 'Google Pixel 7' : 'iPhone 15'),
41
+ osVersion: opts.osVersion ?? (opts.platform === 'android' ? '13.0' : '17'),
42
+ realMobile: true,
43
+ projectName: 'ZeTa-QIP',
44
+ buildName: `${opts.platform}-crawl`,
45
+ sessionName: opts.jobId,
46
+ };
47
+ if (opts.platform === 'android') {
48
+ return {
49
+ 'platformName': 'android',
50
+ 'appium:automationName': 'UiAutomator2',
51
+ 'appium:app': localAppPath,
52
+ 'appium:newCommandTimeout': 120,
53
+ 'appium:autoGrantPermissions': true,
54
+ ...(opts.appActivity ? { 'appium:appActivity': opts.appActivity } : {}),
55
+ ...(opts.packageName ? { 'appium:appPackage': opts.packageName } : {}),
56
+ 'bstack:options': bstackOptions,
57
+ };
58
+ }
59
+ return {
60
+ 'platformName': 'ios',
61
+ 'appium:automationName': 'XCUITest',
62
+ 'appium:app': localAppPath,
63
+ 'appium:newCommandTimeout': 120,
64
+ ...(opts.packageName ? { 'appium:bundleId': opts.packageName } : {}),
65
+ 'bstack:options': bstackOptions,
66
+ };
33
67
  }
68
+ if (opts.platform === 'android') {
69
+ return {
70
+ 'appium:platformName': 'Android',
71
+ 'appium:deviceName': opts.deviceModel ?? 'Pixel 7',
72
+ 'appium:app': localAppPath,
73
+ 'appium:automationName': 'UiAutomator2',
74
+ 'appium:autoGrantPermissions': true,
75
+ 'appium:newCommandTimeout': 120,
76
+ 'appium:platformVersion': opts.osVersion ?? '',
77
+ ...(opts.appActivity ? { 'appium:appActivity': opts.appActivity } : {}),
78
+ ...(opts.appWaitActivity ? { 'appium:appWaitActivity': opts.appWaitActivity } : {}),
79
+ 'appium:eventTimings': true, // capture launch time telemetry
80
+ ...(opts.packageName ? { 'appium:appPackage': opts.packageName } : {}),
81
+ };
82
+ }
83
+ if (opts.platform === 'windows') {
84
+ return {
85
+ 'platformName': 'Windows',
86
+ 'appium:automationName': 'Windows',
87
+ 'appium:app': localAppPath,
88
+ 'appium:deviceName': 'WindowsPC',
89
+ 'appium:newCommandTimeout': 120,
90
+ ...(opts.packageName ? { 'appium:appTopLevelWindow': opts.packageName } : {}),
91
+ };
92
+ }
93
+ if (opts.platform === 'macos') {
94
+ return {
95
+ 'platformName': 'mac',
96
+ 'appium:automationName': 'Mac2',
97
+ 'appium:app': localAppPath,
98
+ 'appium:deviceName': 'Mac',
99
+ 'appium:newCommandTimeout': 120,
100
+ ...(opts.packageName ? { 'appium:bundleId': opts.packageName } : {}),
101
+ };
102
+ }
103
+ // iOS
34
104
  return {
35
- "platformName": "ios",
36
- "appium:automationName": "XCUITest",
37
- "appium:app": localAppPath,
38
- "appium:newCommandTimeout": 120,
39
- ...opts.packageName ? { "appium:bundleId": opts.packageName } : {},
40
- "bstack:options": bstackOptions
41
- };
42
- }
43
- if (opts.platform === "android") {
44
- return {
45
- "appium:platformName": "Android",
46
- "appium:deviceName": opts.deviceModel ?? "Pixel 7",
47
- "appium:app": localAppPath,
48
- "appium:automationName": "UiAutomator2",
49
- "appium:autoGrantPermissions": true,
50
- "appium:newCommandTimeout": 120,
51
- "appium:platformVersion": opts.osVersion ?? "",
52
- ...opts.appActivity ? { "appium:appActivity": opts.appActivity } : {},
53
- ...opts.appWaitActivity ? { "appium:appWaitActivity": opts.appWaitActivity } : {},
54
- "appium:eventTimings": true,
55
- // capture launch time telemetry
56
- ...opts.packageName ? { "appium:appPackage": opts.packageName } : {}
57
- };
58
- }
59
- if (opts.platform === "windows") {
60
- return {
61
- "platformName": "Windows",
62
- "appium:automationName": "Windows",
63
- "appium:app": localAppPath,
64
- "appium:deviceName": "WindowsPC",
65
- "appium:newCommandTimeout": 120,
66
- ...opts.packageName ? { "appium:appTopLevelWindow": opts.packageName } : {}
67
- };
68
- }
69
- if (opts.platform === "macos") {
70
- return {
71
- "platformName": "mac",
72
- "appium:automationName": "Mac2",
73
- "appium:app": localAppPath,
74
- "appium:deviceName": "Mac",
75
- "appium:newCommandTimeout": 120,
76
- ...opts.packageName ? { "appium:bundleId": opts.packageName } : {}
105
+ 'appium:platformName': 'iOS',
106
+ 'appium:deviceName': opts.deviceModel ?? 'iPhone 15',
107
+ 'appium:app': localAppPath,
108
+ 'appium:automationName': 'XCUITest',
109
+ 'appium:newCommandTimeout': 120,
110
+ 'appium:platformVersion': opts.osVersion ?? '',
111
+ ...(opts.packageName ? { 'appium:bundleId': opts.packageName } : {}),
77
112
  };
78
- }
79
- return {
80
- "appium:platformName": "iOS",
81
- "appium:deviceName": opts.deviceModel ?? "iPhone 15",
82
- "appium:app": localAppPath,
83
- "appium:automationName": "XCUITest",
84
- "appium:newCommandTimeout": 120,
85
- "appium:platformVersion": opts.osVersion ?? "",
86
- ...opts.packageName ? { "appium:bundleId": opts.packageName } : {}
87
- };
88
113
  }
114
+ /**
115
+ * Upload a locally downloaded app binary to BrowserStack App Automate.
116
+ * Returns the bs:// app URL to use as the Appium capability value.
117
+ */
89
118
  async function uploadToBrowserStack(localPath, opts) {
90
- const username = opts.browserstackUsername ?? process.env.BROWSERSTACK_USERNAME ?? "";
91
- const accessKey = opts.browserstackAccessKey ?? process.env.BROWSERSTACK_ACCESS_KEY ?? "";
92
- const credentials = Buffer.from(`${username}:${accessKey}`).toString("base64");
93
- const fileBuffer = await fs.readFile(localPath);
94
- const filename = localPath.split("/").pop() ?? "app.apk";
95
- const form = new global.FormData();
96
- form.append("file", new Blob([new Uint8Array(fileBuffer)]), filename);
97
- logger.info({ localPath, filename }, "Uploading app binary to BrowserStack");
98
- const response = await fetch("https://api-cloud.browserstack.com/app-automate/upload", {
99
- method: "POST",
100
- headers: { Authorization: `Basic ${credentials}` },
101
- body: form
102
- });
103
- if (!response.ok) {
104
- throw new Error(`BrowserStack upload failed: ${response.status} ${await response.text()}`);
105
- }
106
- const json = await response.json();
107
- if (!json.app_url) throw new Error("BrowserStack upload did not return app_url");
108
- logger.info({ appUrl: json.app_url }, "App uploaded to BrowserStack");
109
- return json.app_url;
119
+ const username = opts.browserstackUsername ?? process.env.BROWSERSTACK_USERNAME ?? '';
120
+ const accessKey = opts.browserstackAccessKey ?? process.env.BROWSERSTACK_ACCESS_KEY ?? '';
121
+ const credentials = Buffer.from(`${username}:${accessKey}`).toString('base64');
122
+ const fileBuffer = await fs.readFile(localPath);
123
+ const filename = localPath.split('/').pop() ?? 'app.apk';
124
+ const form = new global.FormData();
125
+ form.append('file', new Blob([new Uint8Array(fileBuffer)]), filename);
126
+ logger.info({ localPath, filename }, 'Uploading app binary to BrowserStack');
127
+ const response = await fetch('https://api-cloud.browserstack.com/app-automate/upload', {
128
+ method: 'POST',
129
+ headers: { Authorization: `Basic ${credentials}` },
130
+ body: form,
131
+ });
132
+ if (!response.ok) {
133
+ throw new Error(`BrowserStack upload failed: ${response.status} ${await response.text()}`);
134
+ }
135
+ const json = (await response.json());
136
+ if (!json.app_url)
137
+ throw new Error('BrowserStack upload did not return app_url');
138
+ logger.info({ appUrl: json.app_url }, 'App uploaded to BrowserStack');
139
+ return json.app_url; // e.g. "bs://1234abc..."
110
140
  }
141
+ /**
142
+ * Download app binary from S3 to a local temp file.
143
+ * For BrowserStack, also uploads the binary to App Automate and returns the bs:// URL.
144
+ * Returns the local file path (or bs:// URL for BrowserStack).
145
+ */
111
146
  async function downloadAppFromS3(appS3Path, platform, opts, storageCfg) {
112
- if (appS3Path.startsWith("local://")) {
113
- const localPath = appS3Path.slice("local://".length);
114
- logger.info({ localPath }, "Using locally stored app binary");
115
- if (opts.deviceFarm === "browserstack") {
116
- return uploadToBrowserStack(localPath, opts);
147
+ // Local disk fallback — APK stored on-server when no cloud storage is configured
148
+ if (appS3Path.startsWith('local://')) {
149
+ const localPath = appS3Path.slice('local://'.length);
150
+ logger.info({ localPath }, 'Using locally stored app binary');
151
+ if (opts.deviceFarm === 'browserstack') {
152
+ return uploadToBrowserStack(localPath, opts);
153
+ }
154
+ return localPath;
155
+ }
156
+ // Parse s3://bucket/key
157
+ const withoutScheme = appS3Path.replace(/^s3:\/\//, '');
158
+ const slashIdx = withoutScheme.indexOf('/');
159
+ if (slashIdx === -1) {
160
+ throw new Error(`Invalid S3 path: ${appS3Path}. Expected format: s3://bucket/path/to/file`);
161
+ }
162
+ const bucket = withoutScheme.slice(0, slashIdx);
163
+ const key = withoutScheme.slice(slashIdx + 1);
164
+ const extMap = { android: '.apk', ios: '.ipa', windows: '.exe', macos: '.app' };
165
+ const ext = extMap[platform] ?? '';
166
+ const tmpPath = path.join(os.tmpdir(), `zeta-native-${Date.now()}${ext}`);
167
+ logger.info({ bucket, key, tmpPath, provider: storageCfg?.provider ?? 'env' }, 'Downloading app binary from storage');
168
+ const { S3Client, GetObjectCommand } = await import('@aws-sdk/client-s3');
169
+ const isR2 = storageCfg?.provider === 'R2';
170
+ const s3ClientCfg = {
171
+ region: isR2 ? 'auto' : (storageCfg?.region ?? process.env.AWS_REGION ?? 'us-east-1'),
172
+ };
173
+ if (storageCfg?.accessKeyId && storageCfg?.secretAccessKey) {
174
+ s3ClientCfg.credentials = { accessKeyId: storageCfg.accessKeyId, secretAccessKey: storageCfg.secretAccessKey };
175
+ }
176
+ if (isR2 && storageCfg?.accountId) {
177
+ s3ClientCfg.endpoint = `https://${storageCfg.accountId}.r2.cloudflarestorage.com`;
178
+ s3ClientCfg.forcePathStyle = true;
117
179
  }
118
- return localPath;
119
- }
120
- const withoutScheme = appS3Path.replace(/^s3:\/\//, "");
121
- const slashIdx = withoutScheme.indexOf("/");
122
- if (slashIdx === -1) {
123
- throw new Error(`Invalid S3 path: ${appS3Path}. Expected format: s3://bucket/path/to/file`);
124
- }
125
- const bucket = withoutScheme.slice(0, slashIdx);
126
- const key = withoutScheme.slice(slashIdx + 1);
127
- const extMap = { android: ".apk", ios: ".ipa", windows: ".exe", macos: ".app" };
128
- const ext = extMap[platform] ?? "";
129
- const tmpPath = path.join(os.tmpdir(), `zeta-native-${Date.now()}${ext}`);
130
- logger.info({ bucket, key, tmpPath, provider: storageCfg?.provider ?? "env" }, "Downloading app binary from storage");
131
- const { S3Client, GetObjectCommand } = await import("@aws-sdk/client-s3");
132
- const isR2 = storageCfg?.provider === "R2";
133
- const s3ClientCfg = {
134
- region: isR2 ? "auto" : storageCfg?.region ?? process.env.AWS_REGION ?? "us-east-1"
135
- };
136
- if (storageCfg?.accessKeyId && storageCfg?.secretAccessKey) {
137
- s3ClientCfg.credentials = { accessKeyId: storageCfg.accessKeyId, secretAccessKey: storageCfg.secretAccessKey };
138
- }
139
- if (isR2 && storageCfg?.accountId) {
140
- s3ClientCfg.endpoint = `https://${storageCfg.accountId}.r2.cloudflarestorage.com`;
141
- s3ClientCfg.forcePathStyle = true;
142
- }
143
- const s3 = new S3Client(s3ClientCfg);
144
- const response = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
145
- const { Readable } = await import("node:stream");
146
- const { pipeline } = await import("node:stream/promises");
147
- const fh = await fs.open(tmpPath, "w");
148
- try {
149
- await pipeline(Readable.from(response.Body), fh.createWriteStream());
150
- } finally {
151
- await fh.close();
152
- }
153
- logger.info({ tmpPath }, "App binary downloaded");
154
- if (opts.deviceFarm === "browserstack") {
155
- return uploadToBrowserStack(tmpPath, opts);
156
- }
157
- return tmpPath;
180
+ const s3 = new S3Client(s3ClientCfg);
181
+ const response = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
182
+ // Stream the body to disk
183
+ const { Readable } = await import('node:stream');
184
+ const { pipeline } = await import('node:stream/promises');
185
+ const fh = await fs.open(tmpPath, 'w');
186
+ try {
187
+ await pipeline(Readable.from(response.Body), fh.createWriteStream());
188
+ }
189
+ finally {
190
+ await fh.close();
191
+ }
192
+ logger.info({ tmpPath }, 'App binary downloaded');
193
+ // For BrowserStack, upload the binary and return the bs:// URL
194
+ if (opts.deviceFarm === 'browserstack') {
195
+ return uploadToBrowserStack(tmpPath, opts);
196
+ }
197
+ return tmpPath;
158
198
  }
199
+ /**
200
+ * Parse the Appium XML page source into a flat list of NativeElement records.
201
+ *
202
+ * Android UiAutomator2 XML looks like:
203
+ * <node text="Login" resource-id="com.example:id/btn_login"
204
+ * class="android.widget.Button" bounds="[100,400][980,520]"
205
+ * clickable="true" enabled="true" />
206
+ *
207
+ * iOS XCUITest XML looks like:
208
+ * <XCUIElementTypeButton name="Login" label="Login"
209
+ * x="50" y="300" width="280" height="44"
210
+ * enabled="true" visible="true" />
211
+ */
159
212
  function parseAccessibilityTree(xmlSource, platform) {
160
- const elements = [];
161
- if (platform === "windows") {
162
- const WIN_INTERACTIVE = /* @__PURE__ */ new Set(["Button", "Edit", "ComboBox", "CheckBox", "RadioButton", "Slider", "ListItem", "MenuItem", "Hyperlink"]);
163
- const nodeRegex = /<(\w+)\s([^>]*)\/>/g;
164
- let match;
165
- while ((match = nodeRegex.exec(xmlSource)) !== null) {
166
- const typeName = match[1] ?? "";
167
- if (!WIN_INTERACTIVE.has(typeName)) continue;
168
- const attrs = match[2] ?? "";
169
- const get = (name) => new RegExp(`\\b${name}="([^"]*)"`, "i").exec(attrs)?.[1] ?? "";
170
- if (get("IsEnabled") === "false" || get("IsOffscreen") === "true") continue;
171
- const label = get("Name") || get("AutomationId") || "";
172
- if (!label) continue;
173
- const role = winTypeToRole(typeName);
174
- const brStr = get("BoundingRectangle");
175
- const brMatch = /x:(\d+)\s+y:(\d+)\s+width:(\d+)\s+height:(\d+)/i.exec(brStr);
176
- const bounds = brMatch ? { x: parseInt(brMatch[1]), y: parseInt(brMatch[2]), width: parseInt(brMatch[3]), height: parseInt(brMatch[4]) } : { x: 0, y: 0, width: 100, height: 30 };
177
- elements.push({ role, label, bounds, platform: "windows", className: typeName });
213
+ const elements = [];
214
+ if (platform === 'windows') {
215
+ // WinAppDriver returns UIAutomation XML like:
216
+ // <Button Name="OK" AutomationId="OkButton" IsEnabled="true" IsOffscreen="false"/>
217
+ const WIN_INTERACTIVE = new Set(['Button', 'Edit', 'ComboBox', 'CheckBox', 'RadioButton', 'Slider', 'ListItem', 'MenuItem', 'Hyperlink']);
218
+ const nodeRegex = /<(\w+)\s([^>]*)\/>/g;
219
+ let match;
220
+ while ((match = nodeRegex.exec(xmlSource)) !== null) {
221
+ const typeName = match[1] ?? '';
222
+ if (!WIN_INTERACTIVE.has(typeName))
223
+ continue;
224
+ const attrs = match[2] ?? '';
225
+ const get = (name) => new RegExp(`\\b${name}="([^"]*)"`, 'i').exec(attrs)?.[1] ?? '';
226
+ if (get('IsEnabled') === 'false' || get('IsOffscreen') === 'true')
227
+ continue;
228
+ const label = get('Name') || get('AutomationId') || '';
229
+ if (!label)
230
+ continue;
231
+ const role = winTypeToRole(typeName);
232
+ // BoundingRectangle="x:0 y:0 width:100 height:30" or similar
233
+ const brStr = get('BoundingRectangle');
234
+ const brMatch = /x:(\d+)\s+y:(\d+)\s+width:(\d+)\s+height:(\d+)/i.exec(brStr);
235
+ const bounds = brMatch
236
+ ? { x: parseInt(brMatch[1]), y: parseInt(brMatch[2]), width: parseInt(brMatch[3]), height: parseInt(brMatch[4]) }
237
+ : { x: 0, y: 0, width: 100, height: 30 };
238
+ elements.push({ role, label, bounds, platform: 'windows', className: typeName });
239
+ }
178
240
  }
179
- }
180
- if (platform === "macos") {
181
- const MAC_INTERACTIVE = /* @__PURE__ */ new Set([
182
- "XCUIElementTypeButton",
183
- "XCUIElementTypeTextField",
184
- "XCUIElementTypeSecureTextField",
185
- "XCUIElementTypeCheckBox",
186
- "XCUIElementTypeRadioButton",
187
- "XCUIElementTypePopUpButton",
188
- "XCUIElementTypeComboBox",
189
- "XCUIElementTypeSlider",
190
- "XCUIElementTypeMenuItem",
191
- "XCUIElementTypeSearchField",
192
- "XCUIElementTypeLink"
193
- ]);
194
- const nodeRegex = /<(XCUIElementType\w+)\s([^>]*)\/>/g;
195
- let match;
196
- while ((match = nodeRegex.exec(xmlSource)) !== null) {
197
- const typeName = match[1] ?? "";
198
- if (!MAC_INTERACTIVE.has(typeName)) continue;
199
- const attrs = match[2] ?? "";
200
- const get = (name) => new RegExp(`\\b${name}="([^"]*)"`, "i").exec(attrs)?.[1] ?? "";
201
- if (get("enabled") === "false") continue;
202
- const label = get("title") || get("label") || get("value") || "";
203
- if (!label) continue;
204
- const x = parseFloat(get("x") || "0");
205
- const y = parseFloat(get("y") || "0");
206
- const width = parseFloat(get("width") || "0");
207
- const height = parseFloat(get("height") || "0");
208
- const role = macTypeToRole(typeName);
209
- elements.push({ role, label, bounds: { x, y, width, height }, platform: "macos", className: typeName });
241
+ if (platform === 'macos') {
242
+ const MAC_INTERACTIVE = new Set([
243
+ 'XCUIElementTypeButton', 'XCUIElementTypeTextField', 'XCUIElementTypeSecureTextField',
244
+ 'XCUIElementTypeCheckBox', 'XCUIElementTypeRadioButton', 'XCUIElementTypePopUpButton',
245
+ 'XCUIElementTypeComboBox', 'XCUIElementTypeSlider', 'XCUIElementTypeMenuItem',
246
+ 'XCUIElementTypeSearchField', 'XCUIElementTypeLink',
247
+ ]);
248
+ const nodeRegex = /<(XCUIElementType\w+)\s([^>]*)\/>/g;
249
+ let match;
250
+ while ((match = nodeRegex.exec(xmlSource)) !== null) {
251
+ const typeName = match[1] ?? '';
252
+ if (!MAC_INTERACTIVE.has(typeName))
253
+ continue;
254
+ const attrs = match[2] ?? '';
255
+ const get = (name) => new RegExp(`\\b${name}="([^"]*)"`, 'i').exec(attrs)?.[1] ?? '';
256
+ if (get('enabled') === 'false')
257
+ continue;
258
+ const label = get('title') || get('label') || get('value') || '';
259
+ if (!label)
260
+ continue;
261
+ const x = parseFloat(get('x') || '0');
262
+ const y = parseFloat(get('y') || '0');
263
+ const width = parseFloat(get('width') || '0');
264
+ const height = parseFloat(get('height') || '0');
265
+ const role = macTypeToRole(typeName);
266
+ elements.push({ role, label, bounds: { x, y, width, height }, platform: 'macos', className: typeName });
267
+ }
210
268
  }
211
- }
212
- if (platform === "android") {
213
- const INTERACTIVE_CLASSES = /* @__PURE__ */ new Set([
214
- "android.widget.Button",
215
- "android.widget.EditText",
216
- "android.widget.CheckBox",
217
- "android.widget.RadioButton",
218
- "android.widget.Switch",
219
- "android.widget.Spinner",
220
- "android.widget.TextView",
221
- // tappable text
222
- "android.widget.ImageButton",
223
- "android.widget.ToggleButton",
224
- "androidx.recyclerview.widget.RecyclerView",
225
- "com.google.android.material.bottomnavigation.BottomNavigationItemView",
226
- "android.widget.ImageView"
227
- // tappable images (nav icons)
228
- ]);
229
- const nodeRegex = /<node\s([^>]*)\/?>/g;
230
- let match;
231
- while ((match = nodeRegex.exec(xmlSource)) !== null) {
232
- const attrs = match[1] ?? "";
233
- const get = (name) => {
234
- const m = new RegExp(`\\b${name}="([^"]*)"`, "i").exec(attrs);
235
- return m ? m[1] ?? "" : "";
236
- };
237
- const className = get("class");
238
- const clickable = get("clickable") === "true";
239
- const enabled = get("enabled") === "true";
240
- if (!enabled) continue;
241
- if (!clickable && !INTERACTIVE_CLASSES.has(className)) continue;
242
- const label = get("content-desc") || get("text") || get("resource-id").split("/").pop() || "";
243
- if (!label) continue;
244
- const resourceId = get("resource-id");
245
- const boundsStr = get("bounds");
246
- const bounds = parseAndroidBounds(boundsStr);
247
- if (!bounds) continue;
248
- const role = classToRole(className);
249
- elements.push({ role, label, bounds, platform: "android", resourceId, className });
269
+ if (platform === 'android') {
270
+ // Match interactive node elements
271
+ const INTERACTIVE_CLASSES = new Set([
272
+ 'android.widget.Button',
273
+ 'android.widget.EditText',
274
+ 'android.widget.CheckBox',
275
+ 'android.widget.RadioButton',
276
+ 'android.widget.Switch',
277
+ 'android.widget.Spinner',
278
+ 'android.widget.TextView', // tappable text
279
+ 'android.widget.ImageButton',
280
+ 'android.widget.ToggleButton',
281
+ 'androidx.recyclerview.widget.RecyclerView',
282
+ 'com.google.android.material.bottomnavigation.BottomNavigationItemView',
283
+ 'android.widget.ImageView', // tappable images (nav icons)
284
+ ]);
285
+ // Simple regex-based node extraction — avoids adding an XML parser dependency.
286
+ // This is intentionally minimal; a full implementation would use a proper XML parser.
287
+ const nodeRegex = /<node\s([^>]*)\/?>/g;
288
+ let match;
289
+ while ((match = nodeRegex.exec(xmlSource)) !== null) {
290
+ const attrs = match[1] ?? '';
291
+ const get = (name) => {
292
+ const m = new RegExp(`\\b${name}="([^"]*)"`, 'i').exec(attrs);
293
+ return m ? m[1] ?? '' : '';
294
+ };
295
+ const className = get('class');
296
+ const clickable = get('clickable') === 'true';
297
+ const enabled = get('enabled') === 'true';
298
+ if (!enabled)
299
+ continue;
300
+ if (!clickable && !INTERACTIVE_CLASSES.has(className))
301
+ continue;
302
+ const label = get('content-desc') || get('text') || get('resource-id').split('/').pop() || '';
303
+ if (!label)
304
+ continue;
305
+ const resourceId = get('resource-id');
306
+ const boundsStr = get('bounds'); // "[x1,y1][x2,y2]"
307
+ const bounds = parseAndroidBounds(boundsStr);
308
+ if (!bounds)
309
+ continue;
310
+ const role = classToRole(className);
311
+ elements.push({ role, label, bounds, platform: 'android', resourceId, className });
312
+ }
250
313
  }
251
- } else {
252
- const INTERACTIVE_TYPES = /* @__PURE__ */ new Set([
253
- "XCUIElementTypeButton",
254
- "XCUIElementTypeTextField",
255
- "XCUIElementTypeSecureTextField",
256
- "XCUIElementTypeSwitch",
257
- "XCUIElementTypeSlider",
258
- "XCUIElementTypeCheckBox",
259
- "XCUIElementTypeRadioButton",
260
- "XCUIElementTypePickerWheel",
261
- "XCUIElementTypeSearchField",
262
- "XCUIElementTypeCell",
263
- "XCUIElementTypeLink",
264
- "XCUIElementTypeTabBarItem",
265
- "XCUIElementTypeNavigationBar"
266
- ]);
267
- const nodeRegex = /<(XCUIElementType\w+)\s([^>]*)\/>/g;
268
- let match;
269
- while ((match = nodeRegex.exec(xmlSource)) !== null) {
270
- const typeName = match[1] ?? "";
271
- if (!INTERACTIVE_TYPES.has(typeName)) continue;
272
- const attrs = match[2] ?? "";
273
- const get = (name) => {
274
- const m = new RegExp(`\\b${name}="([^"]*)"`, "i").exec(attrs);
275
- return m ? m[1] ?? "" : "";
276
- };
277
- if (get("enabled") === "false") continue;
278
- const label = get("label") || get("name") || get("value") || "";
279
- if (!label) continue;
280
- const x = parseFloat(get("x") || "0");
281
- const y = parseFloat(get("y") || "0");
282
- const width = parseFloat(get("width") || "0");
283
- const height = parseFloat(get("height") || "0");
284
- if (width === 0 && height === 0) continue;
285
- const role = xcuiTypeToRole(typeName);
286
- elements.push({ role, label, bounds: { x, y, width, height }, platform: "ios", className: typeName });
314
+ else {
315
+ // iOS — XCUITest element types
316
+ const INTERACTIVE_TYPES = new Set([
317
+ 'XCUIElementTypeButton',
318
+ 'XCUIElementTypeTextField',
319
+ 'XCUIElementTypeSecureTextField',
320
+ 'XCUIElementTypeSwitch',
321
+ 'XCUIElementTypeSlider',
322
+ 'XCUIElementTypeCheckBox',
323
+ 'XCUIElementTypeRadioButton',
324
+ 'XCUIElementTypePickerWheel',
325
+ 'XCUIElementTypeSearchField',
326
+ 'XCUIElementTypeCell',
327
+ 'XCUIElementTypeLink',
328
+ 'XCUIElementTypeTabBarItem',
329
+ 'XCUIElementTypeNavigationBar',
330
+ ]);
331
+ const nodeRegex = /<(XCUIElementType\w+)\s([^>]*)\/>/g;
332
+ let match;
333
+ while ((match = nodeRegex.exec(xmlSource)) !== null) {
334
+ const typeName = match[1] ?? '';
335
+ if (!INTERACTIVE_TYPES.has(typeName))
336
+ continue;
337
+ const attrs = match[2] ?? '';
338
+ const get = (name) => {
339
+ const m = new RegExp(`\\b${name}="([^"]*)"`, 'i').exec(attrs);
340
+ return m ? m[1] ?? '' : '';
341
+ };
342
+ if (get('enabled') === 'false')
343
+ continue;
344
+ const label = get('label') || get('name') || get('value') || '';
345
+ if (!label)
346
+ continue;
347
+ const x = parseFloat(get('x') || '0');
348
+ const y = parseFloat(get('y') || '0');
349
+ const width = parseFloat(get('width') || '0');
350
+ const height = parseFloat(get('height') || '0');
351
+ if (width === 0 && height === 0)
352
+ continue;
353
+ const role = xcuiTypeToRole(typeName);
354
+ elements.push({ role, label, bounds: { x, y, width, height }, platform: 'ios', className: typeName });
355
+ }
287
356
  }
288
- }
289
- return elements;
357
+ return elements;
290
358
  }
359
+ /** Parse Android bounds string "[x1,y1][x2,y2]" → NativeBounds */
291
360
  function parseAndroidBounds(boundsStr) {
292
- const m = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(boundsStr);
293
- if (!m) return null;
294
- const x1 = parseInt(m[1] ?? "0", 10);
295
- const y1 = parseInt(m[2] ?? "0", 10);
296
- const x2 = parseInt(m[3] ?? "0", 10);
297
- const y2 = parseInt(m[4] ?? "0", 10);
298
- return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
361
+ const m = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(boundsStr);
362
+ if (!m)
363
+ return null;
364
+ const x1 = parseInt(m[1] ?? '0', 10);
365
+ const y1 = parseInt(m[2] ?? '0', 10);
366
+ const x2 = parseInt(m[3] ?? '0', 10);
367
+ const y2 = parseInt(m[4] ?? '0', 10);
368
+ return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
299
369
  }
370
+ /** Map Android widget class names to ZeTa element roles */
300
371
  function classToRole(className) {
301
- if (className.includes("Button")) return "button";
302
- if (className.includes("EditText")) return "input";
303
- if (className.includes("CheckBox")) return "checkbox";
304
- if (className.includes("RadioButton")) return "radio";
305
- if (className.includes("Switch") || className.includes("Toggle")) return "switch";
306
- if (className.includes("Spinner")) return "select";
307
- if (className.includes("TextView")) return "text";
308
- return className.split(".").pop()?.toLowerCase() ?? "unknown";
372
+ if (className.includes('Button'))
373
+ return 'button';
374
+ if (className.includes('EditText'))
375
+ return 'input';
376
+ if (className.includes('CheckBox'))
377
+ return 'checkbox';
378
+ if (className.includes('RadioButton'))
379
+ return 'radio';
380
+ if (className.includes('Switch') || className.includes('Toggle'))
381
+ return 'switch';
382
+ if (className.includes('Spinner'))
383
+ return 'select';
384
+ if (className.includes('TextView'))
385
+ return 'text';
386
+ return className.split('.').pop()?.toLowerCase() ?? 'unknown';
309
387
  }
388
+ /** Map XCUITest element type strings to ZeTa element roles */
310
389
  function xcuiTypeToRole(xcuiType) {
311
- if (xcuiType.includes("Button")) return "button";
312
- if (xcuiType.includes("TextField")) return "input";
313
- if (xcuiType.includes("SecureTextField")) return "input";
314
- if (xcuiType.includes("Switch")) return "switch";
315
- if (xcuiType.includes("Slider")) return "slider";
316
- if (xcuiType.includes("CheckBox")) return "checkbox";
317
- if (xcuiType.includes("RadioButton")) return "radio";
318
- if (xcuiType.includes("PickerWheel")) return "select";
319
- if (xcuiType.includes("SearchField")) return "input";
320
- return xcuiType.replace("XCUIElementType", "").toLowerCase();
390
+ if (xcuiType.includes('Button'))
391
+ return 'button';
392
+ if (xcuiType.includes('TextField'))
393
+ return 'input';
394
+ if (xcuiType.includes('SecureTextField'))
395
+ return 'input';
396
+ if (xcuiType.includes('Switch'))
397
+ return 'switch';
398
+ if (xcuiType.includes('Slider'))
399
+ return 'slider';
400
+ if (xcuiType.includes('CheckBox'))
401
+ return 'checkbox';
402
+ if (xcuiType.includes('RadioButton'))
403
+ return 'radio';
404
+ if (xcuiType.includes('PickerWheel'))
405
+ return 'select';
406
+ if (xcuiType.includes('SearchField'))
407
+ return 'input';
408
+ return xcuiType.replace('XCUIElementType', '').toLowerCase();
321
409
  }
410
+ /** Map WinAppDriver UIAutomation element type names to ZeTa element roles */
322
411
  function winTypeToRole(winType) {
323
- if (winType === "Button") return "button";
324
- if (winType === "Edit") return "input";
325
- if (winType === "ComboBox") return "select";
326
- if (winType === "CheckBox") return "checkbox";
327
- if (winType === "RadioButton") return "radio";
328
- if (winType === "Slider") return "slider";
329
- if (winType === "ListItem") return "option";
330
- if (winType === "MenuItem") return "menuitem";
331
- if (winType === "Hyperlink") return "link";
332
- return winType.toLowerCase();
412
+ if (winType === 'Button')
413
+ return 'button';
414
+ if (winType === 'Edit')
415
+ return 'input';
416
+ if (winType === 'ComboBox')
417
+ return 'select';
418
+ if (winType === 'CheckBox')
419
+ return 'checkbox';
420
+ if (winType === 'RadioButton')
421
+ return 'radio';
422
+ if (winType === 'Slider')
423
+ return 'slider';
424
+ if (winType === 'ListItem')
425
+ return 'option';
426
+ if (winType === 'MenuItem')
427
+ return 'menuitem';
428
+ if (winType === 'Hyperlink')
429
+ return 'link';
430
+ return winType.toLowerCase();
333
431
  }
432
+ /** Map Mac2Driver XCUIElementType strings to ZeTa element roles */
334
433
  function macTypeToRole(xcuiType) {
335
- if (xcuiType.includes("Button")) return "button";
336
- if (xcuiType.includes("SecureTextField")) return "input";
337
- if (xcuiType.includes("TextField")) return "input";
338
- if (xcuiType.includes("CheckBox")) return "checkbox";
339
- if (xcuiType.includes("RadioButton")) return "radio";
340
- if (xcuiType.includes("PopUpButton")) return "select";
341
- if (xcuiType.includes("ComboBox")) return "select";
342
- if (xcuiType.includes("Slider")) return "slider";
343
- if (xcuiType.includes("MenuItem")) return "menuitem";
344
- if (xcuiType.includes("SearchField")) return "input";
345
- if (xcuiType.includes("Link")) return "link";
346
- return xcuiType.replace("XCUIElementType", "").toLowerCase();
434
+ if (xcuiType.includes('Button'))
435
+ return 'button';
436
+ if (xcuiType.includes('SecureTextField'))
437
+ return 'input';
438
+ if (xcuiType.includes('TextField'))
439
+ return 'input';
440
+ if (xcuiType.includes('CheckBox'))
441
+ return 'checkbox';
442
+ if (xcuiType.includes('RadioButton'))
443
+ return 'radio';
444
+ if (xcuiType.includes('PopUpButton'))
445
+ return 'select';
446
+ if (xcuiType.includes('ComboBox'))
447
+ return 'select';
448
+ if (xcuiType.includes('Slider'))
449
+ return 'slider';
450
+ if (xcuiType.includes('MenuItem'))
451
+ return 'menuitem';
452
+ if (xcuiType.includes('SearchField'))
453
+ return 'input';
454
+ if (xcuiType.includes('Link'))
455
+ return 'link';
456
+ return xcuiType.replace('XCUIElementType', '').toLowerCase();
347
457
  }
458
+ // ─── Gap 1: Native Auth ──────────────────────────────────────────────────────
459
+ /** Detect if the current screen is a login/auth screen and fill credentials. */
348
460
  async function attemptNativeLogin(driver, platform, credentials) {
349
- try {
350
- const pageXml = await driver.getPageSource();
351
- const elements = parseAccessibilityTree(pageXml, platform);
352
- const inputEls = elements.filter((e) => e.role === "input");
353
- if (inputEls.length === 0) return false;
354
- const isPasswordField = (e) => e.className?.toLowerCase().includes("secure") || e.className?.toLowerCase().includes("password") || e.label?.toLowerCase().match(/password|passcode|pin|secret/i) != null;
355
- const hasPasswordInput = elements.some(isPasswordField);
356
- if (!hasPasswordInput && inputEls.length < 2) return false;
357
- logger.info({ platform }, "Native auth: login screen detected \u2014 filling credentials");
358
- const unLabel = credentials.usernameFieldLabel?.toLowerCase() ?? "";
359
- const usernameEl = inputEls.find((e) => {
360
- if (isPasswordField(e)) return false;
361
- if (unLabel) return e.label?.toLowerCase().includes(unLabel);
362
- return e.label?.toLowerCase().match(/email|username|user|login|phone|mobile/i) != null || true;
363
- }) ?? inputEls[0];
364
- if (usernameEl && credentials.username) {
365
- const uSelector = usernameEl.resourceId ? `id=${usernameEl.resourceId}` : `~${usernameEl.label}`;
366
- try {
367
- const uEl = await driver.$(uSelector);
368
- await uEl.click();
369
- await driver.pause(300);
370
- await uEl.clearValue();
371
- await uEl.setValue(credentials.username);
372
- } catch {
373
- }
374
- }
375
- const pwEl = elements.find(isPasswordField);
376
- if (pwEl && credentials.password) {
377
- const pSelector = pwEl.resourceId ? `id=${pwEl.resourceId}` : `~${pwEl.label}`;
378
- try {
379
- const pEl = await driver.$(pSelector);
380
- await pEl.click();
381
- await driver.pause(300);
382
- await pEl.clearValue();
383
- await pEl.setValue(credentials.password);
384
- } catch {
385
- }
386
- }
387
461
  try {
388
- await driver.hideKeyboard();
389
- } catch {
462
+ const pageXml = await driver.getPageSource();
463
+ const elements = parseAccessibilityTree(pageXml, platform);
464
+ const inputEls = elements.filter((e) => e.role === 'input');
465
+ if (inputEls.length === 0)
466
+ return false;
467
+ // Detect login screen heuristic: has at least one text input and one
468
+ // "password" input (SecureTextField on iOS, password type on Android)
469
+ const isPasswordField = (e) => e.className?.toLowerCase().includes('secure') ||
470
+ e.className?.toLowerCase().includes('password') ||
471
+ e.label?.toLowerCase().match(/password|passcode|pin|secret/i) != null;
472
+ const hasPasswordInput = elements.some(isPasswordField);
473
+ if (!hasPasswordInput && inputEls.length < 2)
474
+ return false;
475
+ logger.info({ platform }, 'Native auth: login screen detected — filling credentials');
476
+ // Fill username field: first non-password input
477
+ const unLabel = credentials.usernameFieldLabel?.toLowerCase() ?? '';
478
+ const usernameEl = inputEls.find((e) => {
479
+ if (isPasswordField(e))
480
+ return false;
481
+ if (unLabel)
482
+ return e.label?.toLowerCase().includes(unLabel);
483
+ return e.label?.toLowerCase().match(/email|username|user|login|phone|mobile/i) != null || true;
484
+ }) ?? inputEls[0];
485
+ if (usernameEl && credentials.username) {
486
+ const uSelector = usernameEl.resourceId
487
+ ? `id=${usernameEl.resourceId}`
488
+ : `~${usernameEl.label}`;
489
+ try {
490
+ const uEl = await driver.$(uSelector);
491
+ await uEl.click();
492
+ await driver.pause(300);
493
+ await uEl.clearValue();
494
+ await uEl.setValue(credentials.username);
495
+ }
496
+ catch { /* non-fatal */ }
497
+ }
498
+ // Fill password field
499
+ const pwEl = elements.find(isPasswordField);
500
+ if (pwEl && credentials.password) {
501
+ const pSelector = pwEl.resourceId ? `id=${pwEl.resourceId}` : `~${pwEl.label}`;
502
+ try {
503
+ const pEl = await driver.$(pSelector);
504
+ await pEl.click();
505
+ await driver.pause(300);
506
+ await pEl.clearValue();
507
+ await pEl.setValue(credentials.password);
508
+ }
509
+ catch { /* non-fatal */ }
510
+ }
511
+ // Dismiss keyboard then tap login button
512
+ try {
513
+ await driver.hideKeyboard();
514
+ }
515
+ catch { /* not always available */ }
516
+ await driver.pause(500);
517
+ const loginLabel = credentials.loginButtonLabel ?? '';
518
+ const loginBtn = elements.find((e) => e.role === 'button' &&
519
+ (loginLabel
520
+ ? e.label?.toLowerCase().includes(loginLabel.toLowerCase())
521
+ : e.label?.toLowerCase().match(/log\s?in|sign\s?in|submit|continue|next|connect/i) != null));
522
+ if (loginBtn) {
523
+ const btnSelector = loginBtn.resourceId ? `id=${loginBtn.resourceId}` : `~${loginBtn.label}`;
524
+ try {
525
+ await driver.$(btnSelector).click();
526
+ await driver.pause(3000); // wait for auth response
527
+ logger.info({ platform }, 'Native auth: login button tapped — waiting for app');
528
+ return true;
529
+ }
530
+ catch { /* non-fatal */ }
531
+ }
532
+ return false;
390
533
  }
391
- await driver.pause(500);
392
- const loginLabel = credentials.loginButtonLabel ?? "";
393
- const loginBtn = elements.find(
394
- (e) => e.role === "button" && (loginLabel ? e.label?.toLowerCase().includes(loginLabel.toLowerCase()) : e.label?.toLowerCase().match(/log\s?in|sign\s?in|submit|continue|next|connect/i) != null)
395
- );
396
- if (loginBtn) {
397
- const btnSelector = loginBtn.resourceId ? `id=${loginBtn.resourceId}` : `~${loginBtn.label}`;
398
- try {
399
- await driver.$(btnSelector).click();
400
- await driver.pause(3e3);
401
- logger.info({ platform }, "Native auth: login button tapped \u2014 waiting for app");
402
- return true;
403
- } catch {
404
- }
534
+ catch (err) {
535
+ logger.warn({ err }, 'Native auth attempt failed — continuing without login');
536
+ return false;
405
537
  }
406
- return false;
407
- } catch (err) {
408
- logger.warn({ err }, "Native auth attempt failed \u2014 continuing without login");
409
- return false;
410
- }
411
538
  }
539
+ // ─── Gap 3: Gesture Navigation ───────────────────────────────────────────────
540
+ /** Perform a swipe gesture on the screen. */
412
541
  async function performSwipe(driver, platform, direction) {
413
- try {
414
- const size = await driver.getWindowSize();
415
- const { width: W, height: H } = size;
416
- const midX = Math.round(W / 2);
417
- const midY = Math.round(H / 2);
418
- if (platform === "android") {
419
- const startX = direction === "left" ? Math.round(W * 0.8) : direction === "right" ? Math.round(W * 0.2) : midX;
420
- const startY = direction === "up" ? Math.round(H * 0.8) : direction === "down" ? Math.round(H * 0.2) : midY;
421
- const endX = direction === "left" ? Math.round(W * 0.2) : direction === "right" ? Math.round(W * 0.8) : midX;
422
- const endY = direction === "up" ? Math.round(H * 0.2) : direction === "down" ? Math.round(H * 0.8) : midY;
423
- await driver.touchAction([
424
- { action: "press", x: startX, y: startY },
425
- { action: "wait", ms: 300 },
426
- { action: "moveTo", x: endX, y: endY },
427
- { action: "release" }
428
- ]);
429
- } else if (platform === "ios") {
430
- await driver.execute("mobile: swipe", { direction });
431
- } else {
432
- await driver.execute("mobile: scroll", { direction });
542
+ try {
543
+ const size = await driver.getWindowSize();
544
+ const { width: W, height: H } = size;
545
+ const midX = Math.round(W / 2);
546
+ const midY = Math.round(H / 2);
547
+ if (platform === 'android') {
548
+ // Use W3C actions for Android
549
+ const startX = direction === 'left' ? Math.round(W * 0.8) : direction === 'right' ? Math.round(W * 0.2) : midX;
550
+ const startY = direction === 'up' ? Math.round(H * 0.8) : direction === 'down' ? Math.round(H * 0.2) : midY;
551
+ const endX = direction === 'left' ? Math.round(W * 0.2) : direction === 'right' ? Math.round(W * 0.8) : midX;
552
+ const endY = direction === 'up' ? Math.round(H * 0.2) : direction === 'down' ? Math.round(H * 0.8) : midY;
553
+ await driver.touchAction([
554
+ { action: 'press', x: startX, y: startY },
555
+ { action: 'wait', ms: 300 },
556
+ { action: 'moveTo', x: endX, y: endY },
557
+ { action: 'release' },
558
+ ]);
559
+ }
560
+ else if (platform === 'ios') {
561
+ await driver.execute('mobile: swipe', { direction });
562
+ }
563
+ else {
564
+ // Windows/macOS: use pointer actions
565
+ await driver.execute('mobile: scroll', { direction });
566
+ }
567
+ await driver.pause(500);
433
568
  }
434
- await driver.pause(500);
435
- } catch {
436
- }
569
+ catch { /* gesture not supported on this platform/driver — non-fatal */ }
437
570
  }
571
+ /** Long-press an element by its selector. */
438
572
  async function performLongPress(driver, selector, durationMs = 1200) {
439
- try {
440
- const el = await driver.$(selector);
441
- await driver.touchAction([
442
- { action: "longPress", element: el, ms: durationMs },
443
- { action: "release" }
444
- ]);
445
- await driver.pause(600);
446
- } catch {
447
- }
573
+ try {
574
+ const el = await driver.$(selector);
575
+ await driver.touchAction([
576
+ { action: 'longPress', element: el, ms: durationMs },
577
+ { action: 'release' },
578
+ ]);
579
+ await driver.pause(600);
580
+ }
581
+ catch { /* non-fatal */ }
448
582
  }
583
+ // ─── Gap 2: Vision Fallback ───────────────────────────────────────────────────
584
+ /** When the accessibility tree yields zero interactive elements, use LLM vision to describe the screen. */
449
585
  async function visionFallbackDescribeScreen(screenshotBase64, llmConfig) {
450
- try {
451
- const Anthropic = (await import("@anthropic-ai/sdk")).default;
452
- if (!llmConfig.apiKey) throw new Error("No LLM API key configured. Configure an Anthropic key in Workspace \u2192 Settings \u2192 AI Provider.");
453
- const client = new Anthropic({ apiKey: llmConfig.apiKey });
454
- const response = await client.messages.create({
455
- model: llmConfig.model ?? "claude-haiku-4-5-20251001",
456
- max_tokens: 512,
457
- messages: [{
458
- role: "user",
459
- content: [
460
- { type: "image", source: { type: "base64", media_type: "image/png", data: screenshotBase64 } },
461
- { 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"},...]' }
462
- ]
463
- }]
464
- });
465
- const text = response.content[0]?.text ?? "";
466
- const jsonMatch = text.match(/\[[\s\S]*\]/);
467
- const elements = jsonMatch ? JSON.parse(jsonMatch[0]) : [];
468
- return { screenDescription: text.split("[")[0]?.trim() ?? "", suggestedElements: elements };
469
- } catch (err) {
470
- logger.warn({ err }, "Vision fallback LLM call failed");
471
- }
472
- return { screenDescription: "Screen captured (accessibility tree empty \u2014 vision fallback unavailable)", suggestedElements: [] };
586
+ try {
587
+ const Anthropic = (await import('@anthropic-ai/sdk')).default;
588
+ if (!llmConfig.apiKey)
589
+ throw new Error('No LLM API key configured. Configure an Anthropic key in Workspace → Settings → AI Provider.');
590
+ const client = new Anthropic({ apiKey: llmConfig.apiKey });
591
+ const response = await client.messages.create({
592
+ model: llmConfig.model ?? 'claude-haiku-4-5-20251001',
593
+ max_tokens: 512,
594
+ messages: [{
595
+ role: 'user',
596
+ content: [
597
+ { type: 'image', source: { type: 'base64', media_type: 'image/png', data: screenshotBase64 } },
598
+ { 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"},...]' },
599
+ ],
600
+ }],
601
+ });
602
+ const text = response.content[0]?.text ?? '';
603
+ const jsonMatch = text.match(/\[[\s\S]*\]/);
604
+ const elements = jsonMatch ? JSON.parse(jsonMatch[0]) : [];
605
+ return { screenDescription: text.split('[')[0]?.trim() ?? '', suggestedElements: elements };
606
+ }
607
+ catch (err) {
608
+ logger.warn({ err }, 'Vision fallback LLM call failed');
609
+ }
610
+ return { screenDescription: 'Screen captured (accessibility tree empty — vision fallback unavailable)', suggestedElements: [] };
473
611
  }
612
+ // ─── Gap 4: MITM Proxy API Capture ───────────────────────────────────────────
613
+ /** Start a lightweight proxy server to intercept native app API traffic. */
474
614
  async function startMitmProxy(port) {
475
- const captured = [];
476
- try {
477
- const net = await import("net");
478
- const server = net.createServer((socket) => {
479
- socket.once("data", (data) => {
480
- const raw = data.toString("utf8", 0, 512);
481
- const match = /^(GET|POST|PUT|DELETE|PATCH|HEAD)\s+(https?:\/\/[^\s]+)/i.exec(raw);
482
- if (match) captured.push({ method: match[1], url: match[2] });
483
- socket.destroy();
484
- });
485
- });
486
- server.listen(port, "127.0.0.1");
487
- const stop = () => server.close();
488
- logger.info({ port }, "MITM proxy listener started (connection-level only \u2014 install device trust cert for full interception)");
489
- return { captured, stop };
490
- } catch (err) {
491
- logger.warn({ err }, "MITM proxy start failed \u2014 API capture disabled");
492
- return { captured, stop: () => {
493
- } };
494
- }
615
+ const captured = [];
616
+ try {
617
+ const net = await import('net');
618
+ // Simple TCP intercept marker — full MITM requires SSL cert installation on device.
619
+ // This records connection attempts; full response interception requires device trust cert.
620
+ const server = net.createServer((socket) => {
621
+ socket.once('data', (data) => {
622
+ const raw = data.toString('utf8', 0, 512);
623
+ const match = /^(GET|POST|PUT|DELETE|PATCH|HEAD)\s+(https?:\/\/[^\s]+)/i.exec(raw);
624
+ if (match)
625
+ captured.push({ method: match[1], url: match[2] });
626
+ socket.destroy();
627
+ });
628
+ });
629
+ server.listen(port, '127.0.0.1');
630
+ const stop = () => server.close();
631
+ logger.info({ port }, 'MITM proxy listener started (connection-level only — install device trust cert for full interception)');
632
+ return { captured, stop };
633
+ }
634
+ catch (err) {
635
+ logger.warn({ err }, 'MITM proxy start failed — API capture disabled');
636
+ return { captured, stop: () => { } };
637
+ }
495
638
  }
639
+ /** Update the PlatformJob status/progress in DB — non-fatal. */
496
640
  async function updateProgress(tenantId, jobId, progress, step) {
497
- try {
498
- await withTenant(
499
- tenantId,
500
- (tx) => tx.platformJob.update({
501
- where: { id: jobId },
502
- data: { progress, result: { step } }
503
- })
504
- );
505
- } catch (err) {
506
- logger.warn({ err }, "Failed to update job progress (non-fatal)");
507
- }
508
- }
509
- async function runNativeCrawl(opts) {
510
- const { tenantId, projectId, jobId, platform } = opts;
511
- const screenshotDir = path.join(config.screenshotDir, projectId);
512
- await fs.mkdir(screenshotDir, { recursive: true });
513
- let tmpAppPath = null;
514
- await withTenant(
515
- tenantId,
516
- (tx) => tx.platformJob.update({
517
- where: { id: jobId },
518
- data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date() }
519
- })
520
- );
521
- const tenantStorageCfg = await getTenantStorageConfig(tenantId).catch(() => null);
522
- try {
523
- await updateProgress(tenantId, jobId, 5, "Downloading app binary");
524
- tmpAppPath = await downloadAppFromS3(opts.appS3Path, platform, opts, tenantStorageCfg);
525
- await updateProgress(tenantId, jobId, 10, "Building Appium session capabilities");
526
- const capabilities = buildCapabilities(opts, tmpAppPath);
527
- await updateProgress(tenantId, jobId, 15, "Starting Appium session");
528
- const appiumServerUrl = opts.appiumServerUrl ?? process.env.APPIUM_SERVER_URL ?? "http://127.0.0.1:4723";
529
641
  try {
530
- const probe = await fetch(`${appiumServerUrl}/status`, { signal: AbortSignal.timeout(5e3) });
531
- if (!probe.ok) throw new Error(`HTTP ${probe.status}`);
532
- } catch (e) {
533
- throw new Error(
534
- `Appium server at ${appiumServerUrl} is not reachable (${e.message}). Start it with: cd /opt/zeta-ai && docker compose -f docker-compose.prod.yml -f docker-compose.appium.yml up -d appium`
535
- );
642
+ await withTenant(tenantId, (tx) => tx.platformJob.update({
643
+ where: { id: jobId },
644
+ data: { progress, result: { step } },
645
+ }));
536
646
  }
537
- const wdio = await import("webdriverio");
538
- const driver = await wdio.remote({
539
- hostname: new URL(appiumServerUrl).hostname,
540
- port: parseInt(new URL(appiumServerUrl).port || "4723", 10),
541
- path: "/",
542
- capabilities,
543
- logLevel: "warn"
544
- });
545
- logger.info({ platform, deviceFarm: opts.deviceFarm }, "Appium session started");
647
+ catch (err) {
648
+ logger.warn({ err }, 'Failed to update job progress (non-fatal)');
649
+ }
650
+ }
651
+ // ---------------------------------------------------------------------------
652
+ // Main export
653
+ // ---------------------------------------------------------------------------
654
+ /**
655
+ * Run a native app crawl session via Appium.
656
+ *
657
+ * Flow:
658
+ * 1. Download APK/IPA from S3 to local temp dir
659
+ * 2. Build Appium WebDriverIO capabilities for the target platform
660
+ * 3. Create WebDriverIO remote session (Appium server at APPIUM_SERVER_URL)
661
+ * 4. For each discovered screen:
662
+ * a. Get page source XML → parse elements
663
+ * b. Take screenshot → save to screenshotDir → persist via AppBrain
664
+ * c. Save screen + elements to DB
665
+ * d. Tap nav elements to discover next screens
666
+ * 5. Update PlatformJob as DONE (or FAILED on error)
667
+ */
668
+ export async function runNativeCrawl(opts) {
669
+ const { tenantId, projectId, jobId, platform } = opts;
670
+ const screenshotDir = path.join(config.screenshotDir, projectId);
671
+ await fs.mkdir(screenshotDir, { recursive: true });
672
+ let tmpAppPath = null;
673
+ // Mark job RUNNING
674
+ await withTenant(tenantId, (tx) => tx.platformJob.update({
675
+ where: { id: jobId },
676
+ data: { status: 'RUNNING', startedAt: new Date() },
677
+ }));
678
+ const tenantStorageCfg = await getTenantStorageConfig(tenantId).catch(() => null);
546
679
  try {
547
- const sessionId = driver.sessionId;
548
- await withTenant(
549
- tenantId,
550
- (tx) => tx.platformJob.update({
551
- where: { id: jobId },
552
- data: { appiumSessionId: sessionId }
553
- })
554
- );
555
- await updateProgress(tenantId, jobId, 20, "App launched \u2014 extracting initial screen");
556
- let mitmStop = null;
557
- const mitmCaptured = [];
558
- if (opts.mitmProxyPort) {
559
- const mitm = await startMitmProxy(opts.mitmProxyPort);
560
- mitmStop = mitm.stop;
561
- mitmCaptured.push(...mitm.captured);
562
- }
563
- await driver.pause(2e3);
564
- if (opts.credentials?.username && opts.credentials?.password) {
565
- const loggedIn = await attemptNativeLogin(driver, platform, opts.credentials);
566
- if (loggedIn) await driver.pause(2500);
567
- }
568
- const MAX_SCREENS = 30;
569
- let screensFound = 0;
570
- const tappedSelectors = /* @__PURE__ */ new Set();
571
- for (let i = 0; i < MAX_SCREENS; i++) {
572
- const pct = 20 + Math.round(i / MAX_SCREENS * 60);
573
- await updateProgress(tenantId, jobId, pct, `Capturing screen ${i + 1}`);
574
- let screenName = `Screen ${i + 1}`;
680
+ // ── Step 1: Download app binary from S3 / R2 ────────────────────────────
681
+ await updateProgress(tenantId, jobId, 5, 'Downloading app binary');
682
+ tmpAppPath = await downloadAppFromS3(opts.appS3Path, platform, opts, tenantStorageCfg);
683
+ // ── Step 2: Build Appium capabilities ───────────────────────────────────
684
+ await updateProgress(tenantId, jobId, 10, 'Building Appium session capabilities');
685
+ const capabilities = buildCapabilities(opts, tmpAppPath);
686
+ // ── Step 3: Start WebDriverIO session ───────────────────────────────────
687
+ // TODO: add "webdriverio": "^9.x" to packages/crawler/package.json
688
+ // TODO: requires Appium server running at APPIUM_SERVER_URL env var
689
+ await updateProgress(tenantId, jobId, 15, 'Starting Appium session');
690
+ const appiumServerUrl = opts.appiumServerUrl ?? process.env.APPIUM_SERVER_URL ?? 'http://127.0.0.1:4723';
691
+ // Pre-check connectivity before wdio throws an opaque EAI_AGAIN
575
692
  try {
576
- if (platform === "android") {
577
- const activity = await driver.getCurrentActivity();
578
- screenName = activity.split(".").pop() ?? screenName;
579
- } else if (platform === "ios") {
580
- const titleEl = await driver.$("~NavigationBarTitle").catch(() => null);
581
- if (titleEl) {
582
- const titleText = await titleEl.getText().catch(() => "");
583
- if (titleText) screenName = titleText;
584
- }
585
- } else {
586
- const winTitle = await driver.getTitle().catch(() => "");
587
- if (winTitle) screenName = winTitle;
588
- }
589
- } catch {
693
+ const probe = await fetch(`${appiumServerUrl}/status`, { signal: AbortSignal.timeout(5000) });
694
+ if (!probe.ok)
695
+ throw new Error(`HTTP ${probe.status}`);
590
696
  }
591
- const screenshotBase64 = await driver.takeScreenshot();
592
- const screenshotBuffer = Buffer.from(screenshotBase64, "base64");
593
- let screenshotBuf = screenshotBuffer;
594
- try {
595
- const sharp = (await import("sharp")).default;
596
- const meta = await sharp(screenshotBuffer).metadata();
597
- const logicalSize = await driver.getWindowSize().catch(() => ({ width: meta.width ?? 0, height: meta.height ?? 0 }));
598
- const physW = meta.width ?? 0;
599
- const logW = logicalSize.width;
600
- if (physW > 0 && logW > 0 && physW > logW) {
601
- const logH = Math.round((meta.height ?? physW) * (logW / physW));
602
- screenshotBuf = Buffer.from(await sharp(screenshotBuffer).resize(logW, logH, { fit: "fill" }).png().toBuffer());
603
- }
604
- } catch {
605
- }
606
- const screen = await AppBrain.saveScreen(tenantId, projectId, screenName);
607
- const shotPath = path.join(screenshotDir, `${screen.id}-native-v${screen.version}.png`);
608
- await fs.writeFile(shotPath, screenshotBuf);
609
- await AppBrain.saveScreenshot(tenantId, projectId, screen.id, shotPath, screen.version, "NATIVE");
610
- if (tenantStorageCfg) {
611
- const s3Key = `${tenantId}/${projectId}/screenshots/${screen.id}-native-v${screen.version}.png`;
612
- await uploadScreenshotToTenantStorage(tenantStorageCfg, shotPath, s3Key).catch(() => {
613
- });
697
+ catch (e) {
698
+ throw new Error(`Appium server at ${appiumServerUrl} is not reachable (${e.message}). ` +
699
+ `Start it with: cd /opt/zeta-ai && docker compose -f docker-compose.prod.yml -f docker-compose.appium.yml up -d appium`);
614
700
  }
701
+ // TODO: add "webdriverio": "^9.x" to packages/crawler/package.json before deploying.
702
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
703
+ const wdio = await import('webdriverio');
704
+ const driver = await wdio.remote({
705
+ hostname: new URL(appiumServerUrl).hostname,
706
+ port: parseInt(new URL(appiumServerUrl).port || '4723', 10),
707
+ path: '/',
708
+ capabilities: capabilities,
709
+ logLevel: 'warn',
710
+ });
711
+ logger.info({ platform, deviceFarm: opts.deviceFarm }, 'Appium session started');
615
712
  try {
616
- if (platform === "android") {
617
- await driver.execute("mobile: scroll", { direction: "down" });
618
- } else if (platform === "ios") {
619
- await driver.execute("mobile: scroll", { direction: "down", element: null });
620
- }
621
- await driver.pause(400);
622
- } catch {
623
- }
624
- const pageXml = await driver.getPageSource();
625
- const nativeElements = parseAccessibilityTree(pageXml, platform);
626
- try {
627
- if (platform === "android") {
628
- await driver.execute("mobile: scroll", { direction: "up" });
629
- } else if (platform === "ios") {
630
- await driver.execute("mobile: scroll", { direction: "up", element: null });
631
- }
632
- await driver.pause(300);
633
- } catch {
634
- }
635
- logger.info({ screenName, elements: nativeElements.length }, "Native screen parsed");
636
- if (nativeElements.length === 0 && opts.visionLlmConfig) {
637
- logger.info({ screenName }, "Empty accessibility tree \u2014 triggering vision LLM fallback");
638
- const { suggestedElements } = await visionFallbackDescribeScreen(screenshotBase64, opts.visionLlmConfig);
639
- for (const el of suggestedElements) {
640
- nativeElements.push({
641
- role: el.role,
642
- label: el.label,
643
- resourceId: "",
644
- className: "",
645
- bounds: { x: 0, y: 0, width: 0, height: 0 },
646
- platform
647
- });
648
- }
649
- }
650
- for (const el of nativeElements) {
651
- const boundingRect = {
652
- x: Math.round(el.bounds.x),
653
- y: Math.round(el.bounds.y),
654
- width: Math.round(el.bounds.width),
655
- height: Math.round(el.bounds.height)
656
- };
657
- const notes = JSON.stringify({
658
- ...el.resourceId ? { resourceId: el.resourceId } : {},
659
- ...el.className ? { className: el.className } : {},
660
- platform: el.platform
661
- });
662
- await AppBrain.saveElement(
663
- tenantId,
664
- projectId,
665
- screen.id,
666
- el.label,
667
- // meaning
668
- el.role,
669
- void 0,
670
- // expectedData
671
- notes,
672
- void 0,
673
- // text
674
- true,
675
- // isInteractive
676
- boundingRect,
677
- void 0,
678
- // ariaState
679
- void 0,
680
- // parentLandmark
681
- "NATIVE"
682
- // viewport
683
- );
684
- }
685
- screensFound++;
686
- const navElements = nativeElements.filter(
687
- (el) => ["button", "text"].includes(el.role) || el.label?.match(/menu|home|back|next|continue|tab|nav|dashboard|settings|profile|more|list|search|help|about/i)
688
- );
689
- for (const navEl of navElements.slice(0, 15)) {
690
- if (screensFound >= MAX_SCREENS) break;
691
- try {
692
- let selector;
693
- if (platform === "android" && navEl.resourceId) {
694
- selector = `id=${navEl.resourceId}`;
695
- } else if (navEl.label) {
696
- selector = `~${navEl.label}`;
697
- } else {
698
- continue;
713
+ // Update DB with Appium session ID
714
+ const sessionId = driver.sessionId;
715
+ await withTenant(tenantId, (tx) => tx.platformJob.update({
716
+ where: { id: jobId },
717
+ data: { appiumSessionId: sessionId },
718
+ }));
719
+ // ── Step 4: Crawl screens ─────────────────────────────────────────────
720
+ await updateProgress(tenantId, jobId, 20, 'App launched — extracting initial screen');
721
+ // Gap 4: Start MITM proxy for API traffic capture
722
+ let mitmStop = null;
723
+ const mitmCaptured = [];
724
+ if (opts.mitmProxyPort) {
725
+ const mitm = await startMitmProxy(opts.mitmProxyPort);
726
+ mitmStop = mitm.stop;
727
+ mitmCaptured.push(...mitm.captured);
699
728
  }
700
- if (tappedSelectors.has(selector)) continue;
701
- tappedSelectors.add(selector);
702
- let preActivity = "";
703
- try {
704
- if (platform === "android") {
705
- preActivity = await driver.getCurrentActivity();
706
- } else {
707
- preActivity = await driver.getTitle();
708
- }
709
- } catch {
729
+ // Allow the app to reach its initial state
730
+ await driver.pause(2000);
731
+ // Gap 1: Attempt native login before BFS begins
732
+ if (opts.credentials?.username && opts.credentials?.password) {
733
+ const loggedIn = await attemptNativeLogin(driver, platform, opts.credentials);
734
+ if (loggedIn)
735
+ await driver.pause(2500); // wait for post-login navigation
710
736
  }
711
- await driver.$(selector).click();
712
- await driver.pause(1500);
713
- if (platform === "android") {
714
- try {
715
- const logs = await driver.getLogs("logcat");
716
- const crashLog = logs.find(
717
- (l) => typeof l.message === "string" && /FATAL EXCEPTION|Process.*has died|ANR in/i.test(l.message)
718
- );
719
- if (crashLog) {
720
- logger.error({ jobId, crash: String(crashLog.message).slice(0, 500) }, "App crash detected during crawl");
721
- break;
737
+ // Capture launch screen then navigate forward via discoverable nav elements.
738
+ const MAX_SCREENS = 30;
739
+ let screensFound = 0;
740
+ const tappedSelectors = new Set();
741
+ for (let i = 0; i < MAX_SCREENS; i++) {
742
+ const pct = 20 + Math.round((i / MAX_SCREENS) * 60);
743
+ await updateProgress(tenantId, jobId, pct, `Capturing screen ${i + 1}`);
744
+ // a. Get screen title / name heuristic
745
+ let screenName = `Screen ${i + 1}`;
746
+ try {
747
+ // Android: use current activity name; iOS: use app title from accessibility tree
748
+ // Windows/macOS: use driver.getTitle() for the window title
749
+ if (platform === 'android') {
750
+ const activity = await driver.getCurrentActivity();
751
+ screenName = activity.split('.').pop() ?? screenName;
752
+ }
753
+ else if (platform === 'ios') {
754
+ // iOS: attempt to read the navigation bar title
755
+ const titleEl = await driver.$('~NavigationBarTitle').catch(() => null);
756
+ if (titleEl) {
757
+ const titleText = await titleEl.getText().catch(() => '');
758
+ if (titleText)
759
+ screenName = titleText;
760
+ }
761
+ }
762
+ else {
763
+ // windows / macos: use window title
764
+ const winTitle = await driver.getTitle().catch(() => '');
765
+ if (winTitle)
766
+ screenName = winTitle;
767
+ }
722
768
  }
723
- } catch {
724
- }
725
- }
726
- if (platform === "ios") {
727
- const crashLogs = await driver.getLogs("crashlog").catch(() => []);
728
- if (crashLogs.length > 0 && crashLogs.some((l) => l.message?.includes("Exception") || l.message?.includes("crash"))) {
729
- logger.warn({ screenName }, "iOS crash log detected \u2014 stopping crawl");
730
- break;
731
- }
732
- }
733
- let postActivity = "";
734
- try {
735
- if (platform === "android") {
736
- postActivity = await driver.getCurrentActivity();
737
- } else {
738
- postActivity = await driver.getTitle();
739
- }
740
- } catch {
741
- }
742
- if (postActivity === preActivity) continue;
743
- if (screensFound >= MAX_SCREENS) break;
744
- const newScreenName = postActivity ? postActivity.split(".").pop() ?? `Screen ${screensFound + 1}` : `Screen ${screensFound + 1}`;
745
- const newScreenshot = await driver.takeScreenshot();
746
- const newScreen = await AppBrain.saveScreen(tenantId, projectId, newScreenName);
747
- const newShotPath = path.join(screenshotDir, `${newScreen.id}-native-v${newScreen.version}.png`);
748
- await fs.writeFile(newShotPath, Buffer.from(newScreenshot, "base64"));
749
- await AppBrain.saveScreenshot(tenantId, projectId, newScreen.id, newShotPath, newScreen.version, "NATIVE");
750
- if (tenantStorageCfg) {
751
- const s3Key = `${tenantId}/${projectId}/screenshots/${newScreen.id}-native-v${newScreen.version}.png`;
752
- await uploadScreenshotToTenantStorage(tenantStorageCfg, newShotPath, s3Key).catch(() => {
753
- });
754
- }
755
- const newXml = await driver.getPageSource();
756
- const newElements = parseAccessibilityTree(newXml, platform);
757
- for (const el of newElements) {
758
- await AppBrain.saveElement(
759
- tenantId,
760
- projectId,
761
- newScreen.id,
762
- el.label,
763
- el.role,
764
- void 0,
765
- JSON.stringify({ ...el.resourceId ? { resourceId: el.resourceId } : {}, ...el.className ? { className: el.className } : {}, platform: el.platform }),
766
- void 0,
767
- true,
768
- { x: Math.round(el.bounds.x), y: Math.round(el.bounds.y), width: Math.round(el.bounds.width), height: Math.round(el.bounds.height) },
769
- void 0,
770
- void 0,
771
- "NATIVE"
772
- );
769
+ catch { /* non-fatal — use default name */ }
770
+ // b. Take screenshot
771
+ const screenshotBase64 = await driver.takeScreenshot();
772
+ const screenshotBuffer = Buffer.from(screenshotBase64, 'base64');
773
+ // DPR normalization: resize physical pixels → logical pixels
774
+ let screenshotBuf = screenshotBuffer;
775
+ try {
776
+ const sharp = (await import('sharp')).default;
777
+ const meta = await sharp(screenshotBuffer).metadata();
778
+ const logicalSize = await driver.getWindowSize().catch(() => ({ width: meta.width ?? 0, height: meta.height ?? 0 }));
779
+ const physW = meta.width ?? 0;
780
+ const logW = logicalSize.width;
781
+ if (physW > 0 && logW > 0 && physW > logW) {
782
+ const logH = Math.round((meta.height ?? physW) * (logW / physW));
783
+ screenshotBuf = Buffer.from(await sharp(screenshotBuffer).resize(logW, logH, { fit: 'fill' }).png().toBuffer());
784
+ }
785
+ }
786
+ catch { /* sharp unavailable or resize failed — use raw buffer */ }
787
+ // Save screen record first (to get screen.id)
788
+ const screen = await AppBrain.saveScreen(tenantId, projectId, screenName);
789
+ const shotPath = path.join(screenshotDir, `${screen.id}-native-v${screen.version}.png`);
790
+ await fs.writeFile(shotPath, screenshotBuf);
791
+ await AppBrain.saveScreenshot(tenantId, projectId, screen.id, shotPath, screen.version, 'NATIVE');
792
+ if (tenantStorageCfg) {
793
+ const s3Key = `${tenantId}/${projectId}/screenshots/${screen.id}-native-v${screen.version}.png`;
794
+ await uploadScreenshotToTenantStorage(tenantStorageCfg, shotPath, s3Key).catch(() => { });
795
+ }
796
+ // c. Parse accessibility tree → save elements
797
+ // Scroll down to reveal below-fold elements, then scroll back up before parsing
798
+ try {
799
+ if (platform === 'android') {
800
+ await driver.execute('mobile: scroll', { direction: 'down' });
801
+ }
802
+ else if (platform === 'ios') {
803
+ await driver.execute('mobile: scroll', { direction: 'down', element: null });
804
+ }
805
+ await driver.pause(400);
806
+ }
807
+ catch { /* scroll not always available — non-fatal */ }
808
+ const pageXml = await driver.getPageSource();
809
+ const nativeElements = parseAccessibilityTree(pageXml, platform);
810
+ try {
811
+ if (platform === 'android') {
812
+ await driver.execute('mobile: scroll', { direction: 'up' });
813
+ }
814
+ else if (platform === 'ios') {
815
+ await driver.execute('mobile: scroll', { direction: 'up', element: null });
816
+ }
817
+ await driver.pause(300);
818
+ }
819
+ catch { /* non-fatal */ }
820
+ logger.info({ screenName, elements: nativeElements.length }, 'Native screen parsed');
821
+ // Gap 2: Vision fallback when accessibility tree is empty (Flutter / game engines)
822
+ if (nativeElements.length === 0 && opts.visionLlmConfig) {
823
+ logger.info({ screenName }, 'Empty accessibility tree — triggering vision LLM fallback');
824
+ const { suggestedElements } = await visionFallbackDescribeScreen(screenshotBase64, opts.visionLlmConfig);
825
+ for (const el of suggestedElements) {
826
+ nativeElements.push({
827
+ role: el.role, label: el.label, resourceId: '', className: '',
828
+ bounds: { x: 0, y: 0, width: 0, height: 0 }, platform,
829
+ });
830
+ }
831
+ }
832
+ for (const el of nativeElements) {
833
+ const boundingRect = {
834
+ x: Math.round(el.bounds.x),
835
+ y: Math.round(el.bounds.y),
836
+ width: Math.round(el.bounds.width),
837
+ height: Math.round(el.bounds.height),
838
+ };
839
+ const notes = JSON.stringify({
840
+ ...(el.resourceId ? { resourceId: el.resourceId } : {}),
841
+ ...(el.className ? { className: el.className } : {}),
842
+ platform: el.platform,
843
+ });
844
+ await AppBrain.saveElement(tenantId, projectId, screen.id, el.label, // meaning
845
+ el.role, undefined, // expectedData
846
+ notes, undefined, // text
847
+ true, // isInteractive
848
+ boundingRect, undefined, // ariaState
849
+ undefined, // parentLandmark
850
+ 'NATIVE');
851
+ }
852
+ screensFound++;
853
+ // d. Navigate to next screen — basic BFS via nav-like elements
854
+ const navElements = nativeElements.filter(el => ['button', 'text'].includes(el.role) ||
855
+ el.label?.match(/menu|home|back|next|continue|tab|nav|dashboard|settings|profile|more|list|search|help|about/i));
856
+ for (const navEl of navElements.slice(0, 15)) {
857
+ if (screensFound >= MAX_SCREENS)
858
+ break;
859
+ try {
860
+ // Build a selector for this element
861
+ let selector;
862
+ if (platform === 'android' && navEl.resourceId) {
863
+ selector = `id=${navEl.resourceId}`;
864
+ }
865
+ else if (navEl.label) {
866
+ selector = `~${navEl.label}`;
867
+ }
868
+ else {
869
+ continue;
870
+ }
871
+ if (tappedSelectors.has(selector))
872
+ continue;
873
+ tappedSelectors.add(selector);
874
+ // Capture pre-tap screen identity
875
+ let preActivity = '';
876
+ try {
877
+ if (platform === 'android') {
878
+ preActivity = await driver.getCurrentActivity();
879
+ }
880
+ else {
881
+ preActivity = await driver.getTitle();
882
+ }
883
+ }
884
+ catch { /* non-fatal */ }
885
+ await driver.$(selector).click();
886
+ await driver.pause(1500);
887
+ // Android crash detection via logcat
888
+ if (platform === 'android') {
889
+ try {
890
+ const logs = await driver.getLogs('logcat');
891
+ const crashLog = logs.find((l) => typeof l.message === 'string' && /FATAL EXCEPTION|Process.*has died|ANR in/i.test(l.message));
892
+ if (crashLog) {
893
+ logger.error({ jobId, crash: String(crashLog.message).slice(0, 500) }, 'App crash detected during crawl');
894
+ break; // stop navigating from this screen
895
+ }
896
+ }
897
+ catch { /* logcat not available on all environments — non-fatal */ }
898
+ }
899
+ // iOS crash log detection
900
+ if (platform === 'ios') {
901
+ const crashLogs = await driver.getLogs('crashlog').catch(() => []);
902
+ if (crashLogs.length > 0 && crashLogs.some((l) => l.message?.includes('Exception') || l.message?.includes('crash'))) {
903
+ logger.warn({ screenName }, 'iOS crash log detected — stopping crawl');
904
+ break;
905
+ }
906
+ }
907
+ // Check if screen changed
908
+ let postActivity = '';
909
+ try {
910
+ if (platform === 'android') {
911
+ postActivity = await driver.getCurrentActivity();
912
+ }
913
+ else {
914
+ postActivity = await driver.getTitle();
915
+ }
916
+ }
917
+ catch { /* non-fatal */ }
918
+ if (postActivity === preActivity)
919
+ continue; // same screen — skip
920
+ // Check MAX_SCREENS before saving the new screen
921
+ if (screensFound >= MAX_SCREENS)
922
+ break;
923
+ // New screen — capture it
924
+ const newScreenName = postActivity
925
+ ? postActivity.split('.').pop() ?? `Screen ${screensFound + 1}`
926
+ : `Screen ${screensFound + 1}`;
927
+ const newScreenshot = await driver.takeScreenshot();
928
+ const newScreen = await AppBrain.saveScreen(tenantId, projectId, newScreenName);
929
+ const newShotPath = path.join(screenshotDir, `${newScreen.id}-native-v${newScreen.version}.png`);
930
+ await fs.writeFile(newShotPath, Buffer.from(newScreenshot, 'base64'));
931
+ await AppBrain.saveScreenshot(tenantId, projectId, newScreen.id, newShotPath, newScreen.version, 'NATIVE');
932
+ if (tenantStorageCfg) {
933
+ const s3Key = `${tenantId}/${projectId}/screenshots/${newScreen.id}-native-v${newScreen.version}.png`;
934
+ await uploadScreenshotToTenantStorage(tenantStorageCfg, newShotPath, s3Key).catch(() => { });
935
+ }
936
+ const newXml = await driver.getPageSource();
937
+ const newElements = parseAccessibilityTree(newXml, platform);
938
+ for (const el of newElements) {
939
+ await AppBrain.saveElement(tenantId, projectId, newScreen.id, el.label, el.role, undefined, JSON.stringify({ ...(el.resourceId ? { resourceId: el.resourceId } : {}), ...(el.className ? { className: el.className } : {}), platform: el.platform }), undefined, true, { x: Math.round(el.bounds.x), y: Math.round(el.bounds.y), width: Math.round(el.bounds.width), height: Math.round(el.bounds.height) }, undefined, undefined, 'NATIVE');
940
+ }
941
+ screensFound++;
942
+ // Navigate back so subsequent nav attempts start from a known state
943
+ try {
944
+ await driver.back();
945
+ await driver.pause(800);
946
+ }
947
+ catch { /* non-fatal */ }
948
+ }
949
+ catch { /* element may have disappeared or tap failed — skip */ }
950
+ }
951
+ // Gap 3: Gesture-based discovery — swipe to reveal off-screen content
952
+ if (opts.gestureConfig?.enableSwipe && screensFound < MAX_SCREENS) {
953
+ for (const dir of ['left', 'right', 'up', 'down']) {
954
+ await performSwipe(driver, platform, dir);
955
+ const postSwipeXml = await driver.getPageSource().catch(() => '');
956
+ if (postSwipeXml && postSwipeXml !== pageXml)
957
+ break; // new content found
958
+ }
959
+ }
960
+ // Nav pass complete for this screen — continue BFS to next iteration.
773
961
  }
774
- screensFound++;
962
+ // ── Step 5: Mark job DONE ─────────────────────────────────────────────
963
+ if (mitmStop)
964
+ mitmStop();
965
+ await driver.deleteSession();
966
+ await updateProgress(tenantId, jobId, 99, `Crawl complete — ${screensFound} screen(s) captured`);
967
+ await withTenant(tenantId, (tx) => tx.platformJob.update({
968
+ where: { id: jobId },
969
+ data: {
970
+ status: 'DONE',
971
+ progress: 100,
972
+ finishedAt: new Date(),
973
+ result: { screensFound, platform },
974
+ },
975
+ }));
976
+ logger.info({ jobId, screensFound, platform }, 'Native crawl complete');
977
+ }
978
+ catch (sessionErr) {
979
+ // Best-effort: clean up Appium session before re-throwing
775
980
  try {
776
- await driver.back();
777
- await driver.pause(800);
778
- } catch {
981
+ await driver.deleteSession();
779
982
  }
780
- } catch {
781
- }
782
- }
783
- if (opts.gestureConfig?.enableSwipe && screensFound < MAX_SCREENS) {
784
- for (const dir of ["left", "right", "up", "down"]) {
785
- await performSwipe(driver, platform, dir);
786
- const postSwipeXml = await driver.getPageSource().catch(() => "");
787
- if (postSwipeXml && postSwipeXml !== pageXml) break;
788
- }
983
+ catch { /* ignore */ }
984
+ throw sessionErr;
789
985
  }
790
- }
791
- if (mitmStop) mitmStop();
792
- await driver.deleteSession();
793
- await updateProgress(tenantId, jobId, 99, `Crawl complete \u2014 ${screensFound} screen(s) captured`);
794
- await withTenant(
795
- tenantId,
796
- (tx) => tx.platformJob.update({
797
- where: { id: jobId },
798
- data: {
799
- status: "DONE",
800
- progress: 100,
801
- finishedAt: /* @__PURE__ */ new Date(),
802
- result: { screensFound, platform }
803
- }
804
- })
805
- );
806
- logger.info({ jobId, screensFound, platform }, "Native crawl complete");
807
- } catch (sessionErr) {
808
- try {
809
- await driver.deleteSession();
810
- } catch {
811
- }
812
- throw sessionErr;
813
986
  }
814
- } catch (err) {
815
- logger.error({ jobId, err: String(err) }, "Native crawl failed");
816
- await withTenant(
817
- tenantId,
818
- (tx) => tx.platformJob.update({
819
- where: { id: jobId },
820
- data: {
821
- status: "FAILED",
822
- error: String(err?.message ?? err),
823
- finishedAt: /* @__PURE__ */ new Date()
987
+ catch (err) {
988
+ logger.error({ jobId, err: String(err) }, 'Native crawl failed');
989
+ await withTenant(tenantId, (tx) => tx.platformJob.update({
990
+ where: { id: jobId },
991
+ data: {
992
+ status: 'FAILED',
993
+ error: String(err?.message ?? err),
994
+ finishedAt: new Date(),
995
+ },
996
+ })).catch(() => { });
997
+ throw err;
998
+ }
999
+ finally {
1000
+ // Clean up local temp file
1001
+ if (tmpAppPath) {
1002
+ fs.unlink(tmpAppPath).catch(() => { });
824
1003
  }
825
- })
826
- ).catch(() => {
827
- });
828
- throw err;
829
- } finally {
830
- if (tmpAppPath) {
831
- fs.unlink(tmpAppPath).catch(() => {
832
- });
833
1004
  }
834
- }
835
1005
  }
836
- export {
837
- runNativeCrawl
838
- };
839
- //# sourceMappingURL=native-crawler.js.map