@detiq/crawler 1.1.9 → 1.1.11
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/accessibility-auditor.js +54 -74
- package/dist/ai-login.js +227 -249
- package/dist/auth-token-rotator.js +57 -67
- package/dist/auth.js +299 -336
- package/dist/browser-extension.js +104 -142
- package/dist/captcha-solver.js +98 -125
- package/dist/color-contrast.js +77 -89
- package/dist/content-chunker.js +126 -126
- package/dist/content-dedup.js +54 -71
- package/dist/cookie-consent.js +68 -83
- package/dist/cookie-jar.js +53 -60
- package/dist/crawler.js +4494 -5353
- package/dist/custom-js-injector.js +45 -61
- package/dist/dark-mode-capture.js +67 -81
- package/dist/dead-link-detector.js +47 -55
- package/dist/element-hash.js +8 -12
- package/dist/email-otp.js +154 -195
- package/dist/engine-router.js +87 -98
- package/dist/export/bulk-export.js +70 -55
- package/dist/export/vector-export.js +77 -105
- package/dist/extractors/advanced.js +249 -266
- package/dist/extractors/artifact-envelope.js +59 -58
- package/dist/failure-analysis-agent.js +37 -33
- package/dist/feature-flag-detector.js +51 -66
- package/dist/form-detector.js +55 -75
- package/dist/frontier.js +76 -101
- package/dist/gap-pass.js +68 -89
- package/dist/graphql-detector.js +62 -72
- package/dist/http-engine.js +282 -330
- package/dist/incremental-diff.js +54 -87
- package/dist/index.js +67 -28
- package/dist/input-fuzzer.js +93 -97
- package/dist/interactive-probe.js +715 -827
- package/dist/job-queue.js +2151 -2169
- package/dist/js-coverage.js +52 -56
- package/dist/js-router-detector.js +86 -117
- package/dist/language-detector.js +51 -63
- package/dist/link-classifier.js +32 -50
- package/dist/live-view.js +91 -108
- package/dist/locator-extractor.js +151 -187
- package/dist/logger.js +9 -7
- package/dist/markdown-extract.js +48 -61
- package/dist/native-crawler.js +780 -946
- package/dist/network-observer.js +135 -156
- package/dist/network-throttle.js +29 -40
- package/dist/openapi-generator.js +77 -92
- package/dist/pdf-extractor.js +72 -90
- package/dist/performance-metrics.js +80 -102
- package/dist/phash.js +125 -144
- package/dist/pii-scrubber.js +65 -68
- package/dist/playwright-recorder.js +70 -58
- package/dist/policies/crawl-safety.js +31 -34
- package/dist/profiles/domain-profile.js +130 -125
- package/dist/proxy.js +146 -172
- package/dist/rate-limiter.js +58 -65
- package/dist/redirect-tracker.js +60 -64
- package/dist/redis-frontier.js +116 -136
- package/dist/redis-queue.js +157 -144
- package/dist/response-cache.js +127 -140
- package/dist/responsive-detector.js +42 -46
- package/dist/retry-policy.js +46 -50
- package/dist/robots-txt.js +69 -83
- package/dist/screen-reader.js +45 -70
- package/dist/screenshot-masker.js +59 -77
- package/dist/seo-checker.js +88 -107
- package/dist/session-capture.js +192 -212
- package/dist/session-export.js +107 -133
- package/dist/sitemap-writer.js +38 -41
- package/dist/sitemap.js +141 -202
- package/dist/skyvern.js +123 -148
- package/dist/sqs-queue.js +42 -42
- package/dist/sso-token.js +74 -90
- package/dist/stealth/human-behavior.js +156 -173
- package/dist/telemetry.js +68 -82
- package/dist/test-executor.js +309 -335
- package/dist/tls-fingerprint.js +77 -88
- package/dist/url-pattern-completer.js +71 -90
- package/dist/visual-diff-verdict.js +23 -38
- package/dist/webhook-notifier.js +71 -69
- package/dist/websocket-detector.js +56 -68
- package/dist/worker.js +29 -38
- package/package.json +1 -1
- package/src/job-queue.ts +136 -0
package/dist/native-crawler.js
CHANGED
|
@@ -1,1005 +1,839 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
*/
|
|
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";
|
|
32
8
|
function buildCapabilities(opts, localAppPath) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
};
|
|
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
|
-
};
|
|
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
|
+
};
|
|
102
33
|
}
|
|
103
|
-
// iOS
|
|
104
34
|
return {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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 } : {}
|
|
112
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 } : {}
|
|
77
|
+
};
|
|
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
|
+
};
|
|
113
88
|
}
|
|
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
|
-
*/
|
|
118
89
|
async function uploadToBrowserStack(localPath, opts) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return json.app_url; // e.g. "bs://1234abc..."
|
|
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;
|
|
140
110
|
}
|
|
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
|
-
*/
|
|
146
111
|
async function downloadAppFromS3(appS3Path, platform, opts, storageCfg) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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;
|
|
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);
|
|
179
117
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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;
|
|
198
158
|
}
|
|
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
|
-
*/
|
|
212
159
|
function parseAccessibilityTree(xmlSource, platform) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
}
|
|
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 });
|
|
240
178
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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 });
|
|
268
210
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if (!bounds)
|
|
309
|
-
continue;
|
|
310
|
-
const role = classToRole(className);
|
|
311
|
-
elements.push({ role, label, bounds, platform: 'android', resourceId, className });
|
|
312
|
-
}
|
|
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 });
|
|
313
250
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
}
|
|
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 });
|
|
356
287
|
}
|
|
357
|
-
|
|
288
|
+
}
|
|
289
|
+
return elements;
|
|
358
290
|
}
|
|
359
|
-
/** Parse Android bounds string "[x1,y1][x2,y2]" → NativeBounds */
|
|
360
291
|
function parseAndroidBounds(boundsStr) {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
|
|
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 };
|
|
369
299
|
}
|
|
370
|
-
/** Map Android widget class names to ZeTa element roles */
|
|
371
300
|
function classToRole(className) {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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';
|
|
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";
|
|
387
309
|
}
|
|
388
|
-
/** Map XCUITest element type strings to ZeTa element roles */
|
|
389
310
|
function xcuiTypeToRole(xcuiType) {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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();
|
|
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();
|
|
409
321
|
}
|
|
410
|
-
/** Map WinAppDriver UIAutomation element type names to ZeTa element roles */
|
|
411
322
|
function winTypeToRole(winType) {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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();
|
|
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();
|
|
431
333
|
}
|
|
432
|
-
/** Map Mac2Driver XCUIElementType strings to ZeTa element roles */
|
|
433
334
|
function macTypeToRole(xcuiType) {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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();
|
|
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();
|
|
457
347
|
}
|
|
458
|
-
// ─── Gap 1: Native Auth ──────────────────────────────────────────────────────
|
|
459
|
-
/** Detect if the current screen is a login/auth screen and fill credentials. */
|
|
460
348
|
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
|
+
}
|
|
461
387
|
try {
|
|
462
|
-
|
|
463
|
-
|
|
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;
|
|
388
|
+
await driver.hideKeyboard();
|
|
389
|
+
} catch {
|
|
533
390
|
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
+
}
|
|
537
405
|
}
|
|
406
|
+
return false;
|
|
407
|
+
} catch (err) {
|
|
408
|
+
logger.warn({ err }, "Native auth attempt failed \u2014 continuing without login");
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
538
411
|
}
|
|
539
|
-
// ─── Gap 3: Gesture Navigation ───────────────────────────────────────────────
|
|
540
|
-
/** Perform a swipe gesture on the screen. */
|
|
541
412
|
async function performSwipe(driver, platform, direction) {
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
}
|
|
563
|
-
else {
|
|
564
|
-
// Windows/macOS: use pointer actions
|
|
565
|
-
await driver.execute('mobile: scroll', { direction });
|
|
566
|
-
}
|
|
567
|
-
await driver.pause(500);
|
|
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 });
|
|
568
433
|
}
|
|
569
|
-
|
|
434
|
+
await driver.pause(500);
|
|
435
|
+
} catch {
|
|
436
|
+
}
|
|
570
437
|
}
|
|
571
|
-
/** Long-press an element by its selector. */
|
|
572
438
|
async function performLongPress(driver, selector, durationMs = 1200) {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
+
}
|
|
582
448
|
}
|
|
583
|
-
// ─── Gap 2: Vision Fallback ───────────────────────────────────────────────────
|
|
584
|
-
/** When the accessibility tree yields zero interactive elements, use LLM vision to describe the screen. */
|
|
585
449
|
async function visionFallbackDescribeScreen(screenshotBase64, llmConfig) {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
}
|
|
610
|
-
return { screenDescription: 'Screen captured (accessibility tree empty — vision fallback unavailable)', suggestedElements: [] };
|
|
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: [] };
|
|
611
473
|
}
|
|
612
|
-
// ─── Gap 4: MITM Proxy API Capture ───────────────────────────────────────────
|
|
613
|
-
/** Start a lightweight proxy server to intercept native app API traffic. */
|
|
614
474
|
async function startMitmProxy(port) {
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
logger.warn({ err }, 'MITM proxy start failed — API capture disabled');
|
|
636
|
-
return { captured, stop: () => { } };
|
|
637
|
-
}
|
|
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
|
+
}
|
|
638
495
|
}
|
|
639
|
-
/** Update the PlatformJob status/progress in DB — non-fatal. */
|
|
640
496
|
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";
|
|
641
529
|
try {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
+
);
|
|
646
536
|
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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);
|
|
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");
|
|
679
546
|
try {
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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}`;
|
|
692
575
|
try {
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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 {
|
|
696
590
|
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
+
});
|
|
700
614
|
}
|
|
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');
|
|
712
615
|
try {
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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;
|
|
728
699
|
}
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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 {
|
|
736
710
|
}
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
const
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
}
|
|
768
|
-
}
|
|
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 */ }
|
|
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;
|
|
950
722
|
}
|
|
951
|
-
|
|
952
|
-
|
|
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.
|
|
723
|
+
} catch {
|
|
724
|
+
}
|
|
961
725
|
}
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
|
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 = "";
|
|
980
734
|
try {
|
|
981
|
-
|
|
735
|
+
if (platform === "android") {
|
|
736
|
+
postActivity = await driver.getCurrentActivity();
|
|
737
|
+
} else {
|
|
738
|
+
postActivity = await driver.getTitle();
|
|
739
|
+
}
|
|
740
|
+
} catch {
|
|
982
741
|
}
|
|
983
|
-
|
|
984
|
-
|
|
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
|
+
);
|
|
773
|
+
}
|
|
774
|
+
screensFound++;
|
|
775
|
+
try {
|
|
776
|
+
await driver.back();
|
|
777
|
+
await driver.pause(800);
|
|
778
|
+
} catch {
|
|
779
|
+
}
|
|
780
|
+
} catch {
|
|
781
|
+
}
|
|
985
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
|
+
}
|
|
789
|
+
}
|
|
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;
|
|
986
813
|
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
throw err;
|
|
998
|
-
}
|
|
999
|
-
finally {
|
|
1000
|
-
// Clean up local temp file
|
|
1001
|
-
if (tmpAppPath) {
|
|
1002
|
-
fs.unlink(tmpAppPath).catch(() => { });
|
|
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()
|
|
1003
824
|
}
|
|
825
|
+
})
|
|
826
|
+
).catch(() => {
|
|
827
|
+
});
|
|
828
|
+
throw err;
|
|
829
|
+
} finally {
|
|
830
|
+
if (tmpAppPath) {
|
|
831
|
+
fs.unlink(tmpAppPath).catch(() => {
|
|
832
|
+
});
|
|
1004
833
|
}
|
|
834
|
+
}
|
|
1005
835
|
}
|
|
836
|
+
export {
|
|
837
|
+
runNativeCrawl
|
|
838
|
+
};
|
|
839
|
+
//# sourceMappingURL=native-crawler.js.map
|