@detiq/crawler 0.1.3
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/Dockerfile +49 -0
- package/README.md +11 -0
- package/dist/accessibility-auditor.d.ts +29 -0
- package/dist/accessibility-auditor.js +57 -0
- package/dist/accessibility-auditor.js.map +7 -0
- package/dist/ai-login.d.ts +32 -0
- package/dist/ai-login.js +177 -0
- package/dist/ai-login.js.map +7 -0
- package/dist/auth-token-rotator.d.ts +47 -0
- package/dist/auth-token-rotator.js +58 -0
- package/dist/auth-token-rotator.js.map +7 -0
- package/dist/auth.d.ts +20 -0
- package/dist/auth.js +329 -0
- package/dist/auth.js.map +7 -0
- package/dist/browser-extension.d.ts +37 -0
- package/dist/browser-extension.js +113 -0
- package/dist/browser-extension.js.map +7 -0
- package/dist/captcha-solver.d.ts +16 -0
- package/dist/captcha-solver.js +105 -0
- package/dist/captcha-solver.js.map +7 -0
- package/dist/color-contrast.d.ts +30 -0
- package/dist/color-contrast.js +78 -0
- package/dist/color-contrast.js.map +7 -0
- package/dist/content-chunker.d.ts +18 -0
- package/dist/content-chunker.js +152 -0
- package/dist/content-chunker.js.map +7 -0
- package/dist/content-dedup.d.ts +23 -0
- package/dist/content-dedup.js +60 -0
- package/dist/content-dedup.js.map +7 -0
- package/dist/cookie-consent.d.ts +25 -0
- package/dist/cookie-consent.js +78 -0
- package/dist/cookie-consent.js.map +7 -0
- package/dist/cookie-jar.d.ts +20 -0
- package/dist/cookie-jar.js +61 -0
- package/dist/cookie-jar.js.map +7 -0
- package/dist/crawler.d.ts +179 -0
- package/dist/crawler.js +3672 -0
- package/dist/crawler.js.map +7 -0
- package/dist/custom-js-injector.d.ts +42 -0
- package/dist/custom-js-injector.js +50 -0
- package/dist/custom-js-injector.js.map +7 -0
- package/dist/dark-mode-capture.d.ts +23 -0
- package/dist/dark-mode-capture.js +75 -0
- package/dist/dark-mode-capture.js.map +7 -0
- package/dist/dead-link-detector.d.ts +37 -0
- package/dist/dead-link-detector.js +50 -0
- package/dist/dead-link-detector.js.map +7 -0
- package/dist/element-hash.d.ts +9 -0
- package/dist/element-hash.js +9 -0
- package/dist/element-hash.js.map +7 -0
- package/dist/email-otp.d.ts +42 -0
- package/dist/email-otp.js +177 -0
- package/dist/email-otp.js.map +7 -0
- package/dist/engine-router.d.ts +47 -0
- package/dist/engine-router.js +94 -0
- package/dist/engine-router.js.map +7 -0
- package/dist/export/bulk-export.d.ts +26 -0
- package/dist/export/bulk-export.js +76 -0
- package/dist/export/bulk-export.js.map +7 -0
- package/dist/export/vector-export.d.ts +76 -0
- package/dist/export/vector-export.js +85 -0
- package/dist/export/vector-export.js.map +7 -0
- package/dist/extractors/advanced.d.ts +54 -0
- package/dist/extractors/advanced.js +245 -0
- package/dist/extractors/advanced.js.map +7 -0
- package/dist/extractors/artifact-envelope.d.ts +63 -0
- package/dist/extractors/artifact-envelope.js +63 -0
- package/dist/extractors/artifact-envelope.js.map +7 -0
- package/dist/failure-analysis-agent.d.ts +16 -0
- package/dist/failure-analysis-agent.js +40 -0
- package/dist/failure-analysis-agent.js.map +7 -0
- package/dist/feature-flag-detector.d.ts +30 -0
- package/dist/feature-flag-detector.js +55 -0
- package/dist/feature-flag-detector.js.map +7 -0
- package/dist/form-detector.d.ts +34 -0
- package/dist/form-detector.js +58 -0
- package/dist/form-detector.js.map +7 -0
- package/dist/frontier.d.ts +32 -0
- package/dist/frontier.js +83 -0
- package/dist/frontier.js.map +7 -0
- package/dist/gap-pass.d.ts +35 -0
- package/dist/gap-pass.js +81 -0
- package/dist/gap-pass.js.map +7 -0
- package/dist/graphql-detector.d.ts +39 -0
- package/dist/graphql-detector.js +86 -0
- package/dist/graphql-detector.js.map +7 -0
- package/dist/http-engine.d.ts +80 -0
- package/dist/http-engine.js +304 -0
- package/dist/http-engine.js.map +7 -0
- package/dist/incremental-diff.d.ts +59 -0
- package/dist/incremental-diff.js +61 -0
- package/dist/incremental-diff.js.map +7 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +7 -0
- package/dist/input-fuzzer.d.ts +30 -0
- package/dist/input-fuzzer.js +105 -0
- package/dist/input-fuzzer.js.map +7 -0
- package/dist/interactive-probe.d.ts +104 -0
- package/dist/interactive-probe.js +442 -0
- package/dist/interactive-probe.js.map +7 -0
- package/dist/job-queue.d.ts +171 -0
- package/dist/job-queue.js +2256 -0
- package/dist/job-queue.js.map +7 -0
- package/dist/js-coverage.d.ts +29 -0
- package/dist/js-coverage.js +56 -0
- package/dist/js-coverage.js.map +7 -0
- package/dist/js-router-detector.d.ts +17 -0
- package/dist/js-router-detector.js +90 -0
- package/dist/js-router-detector.js.map +7 -0
- package/dist/language-detector.d.ts +22 -0
- package/dist/language-detector.js +57 -0
- package/dist/language-detector.js.map +7 -0
- package/dist/link-classifier.d.ts +23 -0
- package/dist/link-classifier.js +40 -0
- package/dist/link-classifier.js.map +7 -0
- package/dist/live-view.d.ts +36 -0
- package/dist/live-view.js +100 -0
- package/dist/live-view.js.map +7 -0
- package/dist/logger.d.ts +2 -0
- package/dist/logger.js +10 -0
- package/dist/logger.js.map +7 -0
- package/dist/markdown-extract.d.ts +8 -0
- package/dist/markdown-extract.js +54 -0
- package/dist/markdown-extract.js.map +7 -0
- package/dist/native-crawler.d.ts +69 -0
- package/dist/native-crawler.js +839 -0
- package/dist/native-crawler.js.map +7 -0
- package/dist/network-observer.d.ts +49 -0
- package/dist/network-observer.js +149 -0
- package/dist/network-observer.js.map +7 -0
- package/dist/network-throttle.d.ts +25 -0
- package/dist/network-throttle.js +34 -0
- package/dist/network-throttle.js.map +7 -0
- package/dist/openapi-generator.d.ts +49 -0
- package/dist/openapi-generator.js +89 -0
- package/dist/openapi-generator.js.map +7 -0
- package/dist/pdf-extractor.d.ts +28 -0
- package/dist/pdf-extractor.js +81 -0
- package/dist/pdf-extractor.js.map +7 -0
- package/dist/performance-metrics.d.ts +53 -0
- package/dist/performance-metrics.js +83 -0
- package/dist/performance-metrics.js.map +7 -0
- package/dist/phash.d.ts +38 -0
- package/dist/phash.js +115 -0
- package/dist/phash.js.map +7 -0
- package/dist/pii-scrubber.d.ts +21 -0
- package/dist/pii-scrubber.js +75 -0
- package/dist/pii-scrubber.js.map +7 -0
- package/dist/playwright-recorder.d.ts +39 -0
- package/dist/playwright-recorder.js +123 -0
- package/dist/playwright-recorder.js.map +7 -0
- package/dist/policies/crawl-safety.d.ts +9 -0
- package/dist/policies/crawl-safety.js +37 -0
- package/dist/policies/crawl-safety.js.map +7 -0
- package/dist/profiles/domain-profile.d.ts +19 -0
- package/dist/profiles/domain-profile.js +140 -0
- package/dist/profiles/domain-profile.js.map +7 -0
- package/dist/proxy.d.ts +50 -0
- package/dist/proxy.js +165 -0
- package/dist/proxy.js.map +7 -0
- package/dist/rate-limiter.d.ts +31 -0
- package/dist/rate-limiter.js +64 -0
- package/dist/rate-limiter.js.map +7 -0
- package/dist/redirect-tracker.d.ts +37 -0
- package/dist/redirect-tracker.js +65 -0
- package/dist/redirect-tracker.js.map +7 -0
- package/dist/redis-frontier.d.ts +48 -0
- package/dist/redis-frontier.js +126 -0
- package/dist/redis-frontier.js.map +7 -0
- package/dist/redis-queue.d.ts +48 -0
- package/dist/redis-queue.js +165 -0
- package/dist/redis-queue.js.map +7 -0
- package/dist/response-cache.d.ts +38 -0
- package/dist/response-cache.js +140 -0
- package/dist/response-cache.js.map +7 -0
- package/dist/responsive-detector.d.ts +29 -0
- package/dist/responsive-detector.js +46 -0
- package/dist/responsive-detector.js.map +7 -0
- package/dist/retry-policy.d.ts +20 -0
- package/dist/retry-policy.js +54 -0
- package/dist/retry-policy.js.map +7 -0
- package/dist/robots-txt.d.ts +9 -0
- package/dist/robots-txt.js +77 -0
- package/dist/robots-txt.js.map +7 -0
- package/dist/screen-reader.d.ts +16 -0
- package/dist/screen-reader.js +46 -0
- package/dist/screen-reader.js.map +7 -0
- package/dist/screenshot-masker.d.ts +28 -0
- package/dist/screenshot-masker.js +62 -0
- package/dist/screenshot-masker.js.map +7 -0
- package/dist/seo-checker.d.ts +33 -0
- package/dist/seo-checker.js +91 -0
- package/dist/seo-checker.js.map +7 -0
- package/dist/session-capture.d.ts +35 -0
- package/dist/session-capture.js +267 -0
- package/dist/session-capture.js.map +7 -0
- package/dist/session-export.d.ts +49 -0
- package/dist/session-export.js +117 -0
- package/dist/session-export.js.map +7 -0
- package/dist/sitemap-writer.d.ts +21 -0
- package/dist/sitemap-writer.js +47 -0
- package/dist/sitemap-writer.js.map +7 -0
- package/dist/sitemap.d.ts +51 -0
- package/dist/sitemap.js +152 -0
- package/dist/sitemap.js.map +7 -0
- package/dist/skyvern.d.ts +56 -0
- package/dist/skyvern.js +129 -0
- package/dist/skyvern.js.map +7 -0
- package/dist/sqs-queue.d.ts +2 -0
- package/dist/sqs-queue.js +47 -0
- package/dist/sqs-queue.js.map +7 -0
- package/dist/sso-token.d.ts +29 -0
- package/dist/sso-token.js +76 -0
- package/dist/sso-token.js.map +7 -0
- package/dist/stealth/human-behavior.d.ts +20 -0
- package/dist/stealth/human-behavior.js +168 -0
- package/dist/stealth/human-behavior.js.map +7 -0
- package/dist/telemetry.d.ts +34 -0
- package/dist/telemetry.js +77 -0
- package/dist/telemetry.js.map +7 -0
- package/dist/test-executor.d.ts +67 -0
- package/dist/test-executor.js +311 -0
- package/dist/test-executor.js.map +7 -0
- package/dist/tls-fingerprint.d.ts +50 -0
- package/dist/tls-fingerprint.js +81 -0
- package/dist/tls-fingerprint.js.map +7 -0
- package/dist/url-pattern-completer.d.ts +28 -0
- package/dist/url-pattern-completer.js +87 -0
- package/dist/url-pattern-completer.js.map +7 -0
- package/dist/visual-diff-verdict.d.ts +40 -0
- package/dist/visual-diff-verdict.js +25 -0
- package/dist/visual-diff-verdict.js.map +7 -0
- package/dist/webhook-notifier.d.ts +30 -0
- package/dist/webhook-notifier.js +76 -0
- package/dist/webhook-notifier.js.map +7 -0
- package/dist/websocket-detector.d.ts +24 -0
- package/dist/websocket-detector.js +60 -0
- package/dist/websocket-detector.js.map +7 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +33 -0
- package/dist/worker.js.map +7 -0
- package/package.json +45 -0
- package/src/accessibility-auditor.ts +102 -0
- package/src/ai-login.ts +218 -0
- package/src/auth-token-rotator.ts +89 -0
- package/src/auth.ts +396 -0
- package/src/browser-extension.ts +186 -0
- package/src/captcha-solver.ts +143 -0
- package/src/color-contrast.ts +116 -0
- package/src/content-chunker.ts +170 -0
- package/src/content-dedup.ts +99 -0
- package/src/cookie-consent.ts +106 -0
- package/src/cookie-jar.ts +91 -0
- package/src/crawler.ts +4305 -0
- package/src/custom-js-injector.ts +93 -0
- package/src/dark-mode-capture.ts +110 -0
- package/src/dead-link-detector.ts +76 -0
- package/src/element-hash.ts +16 -0
- package/src/email-otp.ts +252 -0
- package/src/engine-router.ts +141 -0
- package/src/export/bulk-export.ts +85 -0
- package/src/export/vector-export.ts +160 -0
- package/src/extractors/advanced.ts +283 -0
- package/src/extractors/artifact-envelope.ts +111 -0
- package/src/failure-analysis-agent.ts +49 -0
- package/src/feature-flag-detector.ts +97 -0
- package/src/form-detector.ts +103 -0
- package/src/frontier.test.ts +64 -0
- package/src/frontier.ts +112 -0
- package/src/gap-pass.ts +122 -0
- package/src/graphql-detector.ts +129 -0
- package/src/http-engine.ts +433 -0
- package/src/incremental-diff.ts +121 -0
- package/src/index.ts +35 -0
- package/src/input-fuzzer.ts +129 -0
- package/src/interactive-probe.ts +623 -0
- package/src/job-queue.test.ts +120 -0
- package/src/job-queue.ts +2701 -0
- package/src/js-coverage.ts +87 -0
- package/src/js-router-detector.ts +112 -0
- package/src/language-detector.ts +82 -0
- package/src/link-classifier.ts +65 -0
- package/src/live-view.ts +141 -0
- package/src/logger.ts +9 -0
- package/src/markdown-extract.test.ts +73 -0
- package/src/markdown-extract.ts +76 -0
- package/src/native-crawler.ts +1106 -0
- package/src/network-observer.ts +204 -0
- package/src/network-throttle.ts +69 -0
- package/src/openapi-generator.ts +135 -0
- package/src/pdf-extractor.ts +123 -0
- package/src/performance-metrics.ts +134 -0
- package/src/phash.ts +151 -0
- package/src/pii-scrubber.ts +99 -0
- package/src/playwright-recorder.ts +170 -0
- package/src/policies/crawl-safety.ts +46 -0
- package/src/profiles/domain-profile.ts +167 -0
- package/src/proxy.ts +238 -0
- package/src/rate-limiter.ts +87 -0
- package/src/redirect-tracker.ts +97 -0
- package/src/redis-frontier.ts +143 -0
- package/src/redis-queue.ts +208 -0
- package/src/response-cache.ts +150 -0
- package/src/responsive-detector.ts +92 -0
- package/src/retry-policy.ts +87 -0
- package/src/robots-txt.ts +96 -0
- package/src/screen-reader.ts +89 -0
- package/src/screenshot-masker.ts +98 -0
- package/src/seo-checker.ts +134 -0
- package/src/session-capture.ts +325 -0
- package/src/session-export.test.ts +75 -0
- package/src/session-export.ts +172 -0
- package/src/sitemap-writer.ts +65 -0
- package/src/sitemap.test.ts +131 -0
- package/src/sitemap.ts +224 -0
- package/src/skyvern.ts +199 -0
- package/src/sqs-queue.ts +49 -0
- package/src/sso-token.ts +120 -0
- package/src/stealth/human-behavior.ts +191 -0
- package/src/telemetry.ts +122 -0
- package/src/test-executor.test.ts +72 -0
- package/src/test-executor.ts +424 -0
- package/src/tls-fingerprint.ts +111 -0
- package/src/url-pattern-completer.ts +114 -0
- package/src/visual-diff-verdict.test.ts +80 -0
- package/src/visual-diff-verdict.ts +57 -0
- package/src/webhook-notifier.ts +109 -0
- package/src/websocket-detector.ts +83 -0
- package/src/worker.ts +48 -0
- package/tsconfig.json +13 -0
- package/types/accessibility-auditor.d.ts +29 -0
- package/types/ai-login.d.ts +32 -0
- package/types/auth-token-rotator.d.ts +47 -0
- package/types/auth.d.ts +20 -0
- package/types/browser-extension.d.ts +37 -0
- package/types/bull-worker.d.ts +6 -0
- package/types/captcha-solver.d.ts +16 -0
- package/types/color-contrast.d.ts +30 -0
- package/types/content-chunker.d.ts +18 -0
- package/types/content-dedup.d.ts +23 -0
- package/types/cookie-consent.d.ts +25 -0
- package/types/cookie-jar.d.ts +20 -0
- package/types/crawler.d.ts +179 -0
- package/types/custom-js-injector.d.ts +42 -0
- package/types/dark-mode-capture.d.ts +23 -0
- package/types/dead-link-detector.d.ts +37 -0
- package/types/element-hash.d.ts +9 -0
- package/types/email-otp.d.ts +42 -0
- package/types/engine-router.d.ts +47 -0
- package/types/export/bulk-export.d.ts +26 -0
- package/types/export/vector-export.d.ts +76 -0
- package/types/extractors/advanced.d.ts +54 -0
- package/types/extractors/artifact-envelope.d.ts +63 -0
- package/types/failure-analysis-agent.d.ts +16 -0
- package/types/feature-flag-detector.d.ts +30 -0
- package/types/form-detector.d.ts +34 -0
- package/types/frontier.d.ts +32 -0
- package/types/gap-pass.d.ts +35 -0
- package/types/graphql-detector.d.ts +39 -0
- package/types/http-engine.d.ts +80 -0
- package/types/incremental-diff.d.ts +59 -0
- package/types/index.d.ts +35 -0
- package/types/input-fuzzer.d.ts +30 -0
- package/types/interactive-probe.d.ts +104 -0
- package/types/job-queue.d.ts +171 -0
- package/types/js-coverage.d.ts +29 -0
- package/types/js-router-detector.d.ts +17 -0
- package/types/language-detector.d.ts +22 -0
- package/types/link-classifier.d.ts +23 -0
- package/types/live-view.d.ts +36 -0
- package/types/logger.d.ts +2 -0
- package/types/markdown-extract.d.ts +8 -0
- package/types/native-crawler.d.ts +69 -0
- package/types/network-observer.d.ts +49 -0
- package/types/network-throttle.d.ts +25 -0
- package/types/openapi-generator.d.ts +49 -0
- package/types/pdf-extractor.d.ts +28 -0
- package/types/performance-metrics.d.ts +53 -0
- package/types/phash.d.ts +38 -0
- package/types/pii-scrubber.d.ts +21 -0
- package/types/playwright-recorder.d.ts +39 -0
- package/types/policies/crawl-safety.d.ts +9 -0
- package/types/profiles/domain-profile.d.ts +19 -0
- package/types/proxy.d.ts +50 -0
- package/types/rate-limiter.d.ts +31 -0
- package/types/redirect-tracker.d.ts +37 -0
- package/types/redis-frontier.d.ts +48 -0
- package/types/redis-queue.d.ts +48 -0
- package/types/response-cache.d.ts +38 -0
- package/types/responsive-detector.d.ts +29 -0
- package/types/retry-policy.d.ts +20 -0
- package/types/robots-txt.d.ts +9 -0
- package/types/screen-reader.d.ts +16 -0
- package/types/screenshot-masker.d.ts +28 -0
- package/types/seo-checker.d.ts +33 -0
- package/types/session-capture.d.ts +38 -0
- package/types/session-export.d.ts +49 -0
- package/types/sitemap-writer.d.ts +21 -0
- package/types/sitemap.d.ts +51 -0
- package/types/skyvern.d.ts +56 -0
- package/types/sqs-queue.d.ts +2 -0
- package/types/sso-token.d.ts +29 -0
- package/types/stealth/human-behavior.d.ts +20 -0
- package/types/telemetry.d.ts +34 -0
- package/types/test-executor.d.ts +67 -0
- package/types/tls-fingerprint.d.ts +50 -0
- package/types/url-pattern-completer.d.ts +28 -0
- package/types/visual-diff-verdict.d.ts +40 -0
- package/types/webhook-notifier.d.ts +30 -0
- package/types/websocket-detector.d.ts +24 -0
- package/types/worker.d.ts +1 -0
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { withTenant } from "@qip/database";
|
|
5
|
+
import { AppBrain, getTenantStorageConfig, uploadScreenshotToTenantStorage } from "@qip/app-brain";
|
|
6
|
+
import { config } from "@qip/core";
|
|
7
|
+
import { logger } from "./logger.js";
|
|
8
|
+
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
|
+
}
|
|
34
|
+
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 } : {}
|
|
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
|
+
};
|
|
88
|
+
}
|
|
89
|
+
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;
|
|
110
|
+
}
|
|
111
|
+
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);
|
|
117
|
+
}
|
|
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;
|
|
158
|
+
}
|
|
159
|
+
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 });
|
|
178
|
+
}
|
|
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 });
|
|
210
|
+
}
|
|
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 });
|
|
250
|
+
}
|
|
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 });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return elements;
|
|
290
|
+
}
|
|
291
|
+
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 };
|
|
299
|
+
}
|
|
300
|
+
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";
|
|
309
|
+
}
|
|
310
|
+
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();
|
|
321
|
+
}
|
|
322
|
+
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();
|
|
333
|
+
}
|
|
334
|
+
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();
|
|
347
|
+
}
|
|
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
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
await driver.hideKeyboard();
|
|
389
|
+
} catch {
|
|
390
|
+
}
|
|
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
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return false;
|
|
407
|
+
} catch (err) {
|
|
408
|
+
logger.warn({ err }, "Native auth attempt failed \u2014 continuing without login");
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
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 });
|
|
433
|
+
}
|
|
434
|
+
await driver.pause(500);
|
|
435
|
+
} catch {
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
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
|
+
}
|
|
448
|
+
}
|
|
449
|
+
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: [] };
|
|
473
|
+
}
|
|
474
|
+
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
|
+
}
|
|
495
|
+
}
|
|
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";
|
|
529
|
+
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
|
+
);
|
|
536
|
+
}
|
|
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");
|
|
546
|
+
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}`;
|
|
575
|
+
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 {
|
|
590
|
+
}
|
|
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
|
+
});
|
|
614
|
+
}
|
|
615
|
+
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;
|
|
699
|
+
}
|
|
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 {
|
|
710
|
+
}
|
|
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;
|
|
722
|
+
}
|
|
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
|
+
);
|
|
773
|
+
}
|
|
774
|
+
screensFound++;
|
|
775
|
+
try {
|
|
776
|
+
await driver.back();
|
|
777
|
+
await driver.pause(800);
|
|
778
|
+
} catch {
|
|
779
|
+
}
|
|
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
|
+
}
|
|
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;
|
|
813
|
+
}
|
|
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()
|
|
824
|
+
}
|
|
825
|
+
})
|
|
826
|
+
).catch(() => {
|
|
827
|
+
});
|
|
828
|
+
throw err;
|
|
829
|
+
} finally {
|
|
830
|
+
if (tmpAppPath) {
|
|
831
|
+
fs.unlink(tmpAppPath).catch(() => {
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
export {
|
|
837
|
+
runNativeCrawl
|
|
838
|
+
};
|
|
839
|
+
//# sourceMappingURL=native-crawler.js.map
|