@mobileaidev/ai-app-bridge 0.2.15 → 0.3.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +306 -53
- package/bin/ai-app-bridge.js +51 -4504
- package/bin/android-permissions.js +152 -0
- package/bin/android-uia-xml.js +74 -0
- package/bin/artifact-paths.js +127 -1
- package/bin/bridge-forward.js +56 -0
- package/bin/command-discovery.js +86 -0
- package/bin/command-errors.js +60 -0
- package/bin/command-registry.js +504 -0
- package/bin/command-request.js +14 -0
- package/bin/command-router.js +80 -0
- package/bin/connection-cache.js +119 -0
- package/bin/device-provider.js +2934 -0
- package/bin/execution-host.js +596 -0
- package/bin/execution-runtime.js +101 -0
- package/bin/fact-codec.js +321 -0
- package/bin/fact-recorder.js +691 -0
- package/bin/fact-store.js +226 -0
- package/bin/feedback-probe.js +285 -0
- package/bin/intent/install-intent.js +267 -0
- package/bin/intent/intent-action-executor.js +129 -0
- package/bin/intent/intent-autonomous-adapter.js +46 -0
- package/bin/intent/intent-capture-port.js +64 -0
- package/bin/intent/intent-entry.js +226 -0
- package/bin/intent/intent-errors.js +28 -0
- package/bin/intent/intent-evidence-store.js +121 -0
- package/bin/intent/intent-lifetime.js +62 -0
- package/bin/intent/intent-observation-target.js +33 -0
- package/bin/intent/intent-observer.js +194 -0
- package/bin/intent/intent-production-adapter.js +334 -0
- package/bin/intent/intent-provider.js +27 -0
- package/bin/intent/intent-runtime.js +63 -0
- package/bin/intent/intent-worker.js +432 -0
- package/bin/intent/ios-intent-adapter.js +90 -0
- package/bin/intent/permission-intent.js +239 -0
- package/bin/intent/web-intent-adapter.js +31 -0
- package/bin/ios-device-outcome.js +47 -0
- package/bin/ios-execution.js +108 -0
- package/bin/ios-provider.js +497 -583
- package/bin/ios-runtime-binding.js +54 -0
- package/bin/ios-wda-execution.js +70 -0
- package/bin/ios-wda-port.js +98 -0
- package/bin/ios-wda-project.js +79 -0
- package/bin/mcp-server.js +83 -1040
- package/bin/mmap-scan-index.js +329 -0
- package/bin/observation-collector.js +862 -0
- package/bin/runtime-client.js +154 -0
- package/bin/runtime-directory.js +107 -0
- package/bin/runtime-protocol.js +36 -0
- package/bin/script/bounded-script-registry.js +103 -0
- package/bin/script/node-runtime-adapter.js +233 -0
- package/bin/script/progress-projector.js +63 -0
- package/bin/script/python-runtime-adapter.js +111 -0
- package/bin/script/rolling-summary.js +134 -0
- package/bin/script/script-agent-port.js +40 -0
- package/bin/script/script-assert.js +95 -0
- package/bin/script/script-capture-port.js +76 -0
- package/bin/script/script-catalog.js +85 -0
- package/bin/script/script-durable-restore.js +195 -0
- package/bin/script/script-entry-code.js +26 -0
- package/bin/script/script-entry-route.js +38 -0
- package/bin/script/script-entry.js +3 -0
- package/bin/script/script-errors.js +29 -0
- package/bin/script/script-evidence-store.js +22 -0
- package/bin/script/script-format-removed.js +26 -0
- package/bin/script/script-host-port.js +397 -0
- package/bin/script/script-ledger.js +64 -0
- package/bin/script/script-result.js +57 -0
- package/bin/script/script-sdk.js +152 -0
- package/bin/script/script-sdk.py +153 -0
- package/bin/script/script-session-channel.js +127 -0
- package/bin/script/script-spec.js +86 -0
- package/bin/script/script-supervisor.js +919 -0
- package/bin/script/templates/checkpoint-reentry.js +13 -0
- package/bin/segment-index.js +481 -0
- package/bin/segmented-fact-store.js +1571 -0
- package/bin/shared-kernel/android-h5-target.js +10 -0
- package/bin/shared-kernel/android-install-execution.js +176 -0
- package/bin/shared-kernel/android-sdk-endpoint.js +42 -0
- package/bin/shared-kernel/android-shell-execution.js +195 -0
- package/bin/shared-kernel/argument-schema.js +117 -0
- package/bin/shared-kernel/canonical-path.js +17 -0
- package/bin/shared-kernel/device-acknowledgements.js +53 -0
- package/bin/shared-kernel/device-completion-history.js +52 -0
- package/bin/shared-kernel/device-mutation-lease.js +219 -0
- package/bin/shared-kernel/device-ownership-recovery.js +95 -0
- package/bin/shared-kernel/device-ownership-store.js +94 -0
- package/bin/shared-kernel/evidence-adapters.js +251 -0
- package/bin/shared-kernel/evidence-archive.js +329 -0
- package/bin/shared-kernel/evidence-recording.js +131 -0
- package/bin/shared-kernel/evidence-schema.js +194 -0
- package/bin/shared-kernel/evidence-store.js +190 -0
- package/bin/shared-kernel/execution-admission.js +22 -0
- package/bin/shared-kernel/execution-contracts.js +171 -0
- package/bin/shared-kernel/execution-io.js +106 -0
- package/bin/shared-kernel/execution-ledger.js +125 -0
- package/bin/shared-kernel/execution-scope.js +87 -0
- package/bin/shared-kernel/execution-target.js +115 -0
- package/bin/shared-kernel/flutter-execution.js +13 -0
- package/bin/shared-kernel/flutter-h5-port.js +60 -0
- package/bin/shared-kernel/flutter-h5-target.js +9 -0
- package/bin/shared-kernel/flutter-target.js +75 -0
- package/bin/shared-kernel/h5-execution.js +11 -0
- package/bin/shared-kernel/h5-target.js +31 -0
- package/bin/shared-kernel/host-fact-store.js +49 -0
- package/bin/shared-kernel/ios-h5-target.js +9 -0
- package/bin/shared-kernel/ios-native-target.js +71 -0
- package/bin/shared-kernel/live-capture-query.js +115 -0
- package/bin/shared-kernel/managed-sdk-execution.js +78 -0
- package/bin/shared-kernel/native-execution.js +13 -0
- package/bin/shared-kernel/native-target.js +156 -0
- package/bin/shared-kernel/provider-command-contracts.js +55 -0
- package/bin/shared-kernel/recorded-payload-archive.js +195 -0
- package/bin/shared-kernel/request-context.js +35 -0
- package/bin/shared-kernel/semantic-node.js +55 -0
- package/bin/shared-kernel/summary-transformer.js +352 -0
- package/bin/shared-kernel/target-lease-protocol.js +47 -0
- package/bin/shared-kernel/text-wait.js +111 -0
- package/bin/shared-kernel/uia-execution.js +96 -0
- package/bin/shared-kernel/uia-protocol.js +214 -0
- package/bin/shared-kernel/uia-runtime-port.js +377 -0
- package/bin/shared-kernel/uia-target.js +39 -0
- package/bin/shared-kernel/web-dom-target.js +44 -0
- package/bin/shared-kernel/xml-attributes.js +25 -0
- package/bin/target-execution.js +275 -0
- package/bin/web/command-schema.js +60 -0
- package/bin/web/session-store.js +157 -0
- package/bin/web-provider.js +334 -553
- package/docs/COMMAND_CONTRACT.md +1563 -0
- package/docs/EVIDENCE_ARCHIVE.md +214 -0
- package/docs/INTENT_FOREGROUND.md +71 -0
- package/docs/INTENT_NATIVE_EDITING.md +79 -0
- package/docs/RELEASE.md +59 -0
- package/docs/SCRIPT_AUTHORING.md +489 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/LICENSE +201 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/NOTICE +7 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/binding.gyp +36 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/bindings/node/sfs_node.c +597 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/include/sfs.h +178 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/index.js +5 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/package.json +24 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/src/sfs.c +2349 -0
- package/package.json +60 -5
- package/runtime/ios-wda/AABWDABinding.h +19 -0
- package/runtime/ios-wda/AABWDABinding.m +97 -0
- package/runtime/ios-wda/AABWDAExecution.h +26 -0
- package/runtime/ios-wda/AABWDAExecution.m +172 -0
- package/runtime/ios-wda/AABWDAIntegration.h +71 -0
- package/runtime/ios-wda/AABWDAManagedRoutes.h +392 -0
- package/runtime/ios-wda/AABWDAReceiptStore.h +10 -0
- package/runtime/ios-wda/AABWDAReceiptStore.m +116 -0
- package/runtime/uia/ai-app-bridge-uia.jar +0 -0
- package/runtime/uia/manifest.json +22 -0
- package/skills/ai-app-bridge-use/SKILL.md +23 -360
package/bin/ai-app-bridge.js
CHANGED
|
@@ -1,442 +1,56 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
2
3
|
|
|
3
|
-
const {
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const os = require('os');
|
|
8
|
-
const path = require('path');
|
|
9
|
-
const { IOSBridgeProvider } = require('./ios-provider');
|
|
10
|
-
const {
|
|
11
|
-
artifactTimestamp,
|
|
12
|
-
defaultArtifactDirectory,
|
|
13
|
-
defaultArtifactPath,
|
|
14
|
-
sanitizeArtifactExtension,
|
|
15
|
-
sanitizeArtifactName,
|
|
16
|
-
} = require('./artifact-paths');
|
|
17
|
-
|
|
18
|
-
const generatedArtifactRetention = 20;
|
|
19
|
-
|
|
20
|
-
const defaults = {
|
|
21
|
-
adb: process.env.ADB || 'adb',
|
|
22
|
-
adbTimeoutMs: Number(process.env.AI_APP_BRIDGE_ADB_TIMEOUT_MS || 15000),
|
|
23
|
-
serial: '',
|
|
24
|
-
port: 18080,
|
|
25
|
-
packageName: 'io.github.mobileaidev.aiappbridge.sample',
|
|
26
|
-
nativeActivity: '.debugbridge.DebugBridgeNativeTestActivity',
|
|
27
|
-
flutterActivity: '.MainActivity',
|
|
28
|
-
};
|
|
4
|
+
const { CommandError, commandFailure } = require('./command-errors');
|
|
5
|
+
const { commandDefinitions, isolatedCommandDefinitions, commandSchema, parseCliOptions } = require('./command-registry');
|
|
6
|
+
const { encodeReply } = require('./runtime-protocol');
|
|
7
|
+
const runtime = require('./runtime-client');
|
|
29
8
|
|
|
30
9
|
const helpText = `Usage: ai-app-bridge <command> [options]
|
|
31
10
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
MCP discovery:
|
|
38
|
-
ai-app-bridge-mcp exposes a compact capability index by default.
|
|
39
|
-
Call capabilities, then run a command from core/app/action/flutter/webview/ios/web/diagnostics/advanced.
|
|
11
|
+
CLI and MCP use one persistent execution runtime for Intent, Script, device
|
|
12
|
+
commands, Web sessions and evidence. A command starts the runtime when needed.
|
|
13
|
+
CLI exit and MCP disconnect leave tasks running. Use runtime --operation stop
|
|
14
|
+
for an orderly runtime shutdown, or intent/script --operation cancel for a task.
|
|
40
15
|
|
|
41
16
|
Commands:
|
|
42
|
-
|
|
43
|
-
tree Read the Android View tree from the in-app bridge.
|
|
44
|
-
uia-tree Read UIAutomator XML for the current foreground window.
|
|
45
|
-
screenshot Capture an ADB screenshot.
|
|
46
|
-
logs Read generic in-app log records.
|
|
47
|
-
logcat Read Android logcat through ADB.
|
|
48
|
-
network Read generic in-app network records.
|
|
49
|
-
state Read generic in-app state records.
|
|
50
|
-
events Read generic in-app event records.
|
|
51
|
-
freeze-app Stop the target app processes with SIGSTOP.
|
|
52
|
-
thaw-app Resume the target app processes with SIGCONT.
|
|
53
|
-
|
|
54
|
-
iOS commands:
|
|
55
|
-
ios-doctor Check Xcode, connected iPhone, runtime, and WDA readiness.
|
|
56
|
-
ios-setup Verify full-control iOS setup; can install/launch app and start WDA when configured.
|
|
57
|
-
ios-devices List iOS devices known to xcrun devicectl.
|
|
58
|
-
ios-install-app Install an iOS .app bundle through devicectl.
|
|
59
|
-
ios-launch-app Launch an iOS app by bundle identifier.
|
|
60
|
-
ios-status Read AiAppBridgeIOS runtime status.
|
|
61
|
-
ios-tree Read UIKit tree from the iOS runtime.
|
|
62
|
-
ios-logs Read in-app iOS log records.
|
|
63
|
-
ios-network Read in-app iOS network records.
|
|
64
|
-
ios-state Read in-app iOS state records.
|
|
65
|
-
ios-events Read in-app iOS event records.
|
|
66
|
-
ios-h5-dom Read WKWebView DOM from the iOS runtime.
|
|
67
|
-
ios-h5-eval Execute JavaScript in a WKWebView through the iOS runtime.
|
|
68
|
-
ios-flutter-tree Read Flutter iOS layout snapshot.
|
|
69
|
-
ios-flutter-nodes Read Flutter iOS operable nodes.
|
|
70
|
-
ios-flutter-action Dispatch a Flutter iOS action through the runtime.
|
|
71
|
-
ios-screenshot Capture an iOS device screenshot through devicectl.
|
|
72
|
-
ios-wda-status Check WebDriverAgent status.
|
|
73
|
-
ios-uia-tree Read XCUITest/WDA UI tree.
|
|
74
|
-
ios-tap Tap iOS device coordinates through WDA.
|
|
75
|
-
ios-input Type text through WDA.
|
|
76
|
-
ios-swipe Swipe through WDA.
|
|
77
|
-
|
|
78
|
-
H5/WebView commands:
|
|
79
|
-
h5-dom Read native Android WebView DOM.
|
|
80
|
-
h5-eval Execute JavaScript in the current native WebView.
|
|
81
|
-
h5-click Click a native WebView element.
|
|
82
|
-
h5-input Set text in a native WebView input.
|
|
83
|
-
h5-wait Wait for native WebView text or selector.
|
|
84
|
-
h5-scroll Scroll native WebView content.
|
|
85
|
-
webview-pages List attachable Android WebView DevTools/CDP pages.
|
|
86
|
-
webview-network Capture WebView Network events through CDP.
|
|
87
|
-
webview-console Capture WebView console/log events through CDP.
|
|
88
|
-
|
|
89
|
-
Flutter commands:
|
|
90
|
-
flutter-tree Read the latest Flutter layout snapshot.
|
|
91
|
-
flutter-nodes Read Flutter operable nodes.
|
|
92
|
-
flutter-action Dispatch a raw Flutter action payload.
|
|
93
|
-
tap-flutter-text Tap a Flutter node by text.
|
|
94
|
-
input-flutter-text Set Flutter text through the Flutter action bridge.
|
|
95
|
-
scroll-flutter Scroll Flutter content.
|
|
96
|
-
flutter-h5-dom Read DOM through a Flutter H5 adapter.
|
|
97
|
-
flutter-h5-eval Execute JavaScript through a Flutter H5 adapter.
|
|
98
|
-
flutter-h5-click Click a Flutter H5 element.
|
|
99
|
-
flutter-h5-input Set text in a Flutter H5 input.
|
|
100
|
-
flutter-h5-wait Wait for Flutter H5 text or selector.
|
|
101
|
-
flutter-h5-scroll Scroll Flutter H5 content.
|
|
102
|
-
|
|
103
|
-
Web Bridge MCP commands:
|
|
104
|
-
web-provider-status Read desktop Web Bridge provider status.
|
|
105
|
-
web-session-start Start the Web Bridge WebSocket session server.
|
|
106
|
-
web-connect-info Read endpoint/token for browser SDK clients.
|
|
107
|
-
web-sessions List connected Web Bridge SDK sessions.
|
|
108
|
-
web-status Read Web Bridge session status.
|
|
109
|
-
web-dom Read or refresh Web Bridge DOM snapshots.
|
|
110
|
-
web-logs Read Web Bridge log records.
|
|
111
|
-
web-network Read Web Bridge network records.
|
|
112
|
-
web-state Read Web Bridge state records.
|
|
113
|
-
web-events Read Web Bridge event records.
|
|
114
|
-
web-command Run a whitelisted browser SDK command.
|
|
115
|
-
web-click Click a DOM element through the browser SDK.
|
|
116
|
-
web-input Set text in a DOM input through the browser SDK.
|
|
117
|
-
web-wait Wait for text or selector through the browser SDK.
|
|
118
|
-
web-scroll Scroll a Web Bridge DOM target.
|
|
119
|
-
|
|
120
|
-
Device/action commands:
|
|
121
|
-
tap Tap device coordinates through ADB.
|
|
122
|
-
tap-text Tap a visible node by exact text or content description.
|
|
123
|
-
tap-uia-text Tap a UIAutomator node by text.
|
|
124
|
-
wait-text Wait until text appears in bridge or UIAutomator output.
|
|
125
|
-
input-text Set native Android text through the in-app bridge; ASCII can fall back to ADB.
|
|
126
|
-
swipe Swipe device coordinates through ADB.
|
|
127
|
-
keyevent Send an Android keyevent through ADB.
|
|
128
|
-
keyboard-state Read Android soft keyboard visibility from dumpsys.
|
|
129
|
-
hide-keyboard Hide the Android soft keyboard when it is visible.
|
|
130
|
-
|
|
131
|
-
App/permission commands:
|
|
132
|
-
install-apk Install an APK and assist device-side installer screens.
|
|
133
|
-
clear-app-data Clear target app local data through the bridge runtime.
|
|
134
|
-
launch-app Launch the target package LAUNCHER Activity.
|
|
135
|
-
launch-activity Launch an explicit Android Activity component.
|
|
136
|
-
launch-native-test Launch the debug native Android bridge test Activity.
|
|
137
|
-
launch-flutter Launch the Flutter Activity.
|
|
138
|
-
permission-state Read Android runtime permission state.
|
|
139
|
-
permission-grant Grant an Android runtime permission.
|
|
140
|
-
permission-revoke Revoke an Android runtime permission.
|
|
141
|
-
permission-dialog Tap a visible Android permission dialog allow button.
|
|
142
|
-
appops-set Set an Android app-op mode.
|
|
143
|
-
|
|
144
|
-
Advanced commands:
|
|
145
|
-
forward Create the ADB port forward for the bridge.
|
|
146
|
-
remove-forward Remove the ADB port forward for the bridge.
|
|
147
|
-
smoke Run the native sample smoke test.
|
|
17
|
+
${[...isolatedCommandDefinitions, ...commandDefinitions].map(d => ` ${d.command.padEnd(22)} ${d.summary}`).join('\n')}
|
|
148
18
|
help Show this help.
|
|
149
19
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
--runtime-url <url> Explicit iOS runtime base URL.
|
|
160
|
-
--wda-url <url> WebDriverAgent base URL; defaults to http://127.0.0.1:8100.
|
|
161
|
-
--wda-session-id <id> Reuse an existing WDA session.
|
|
162
|
-
--wda-project-path <path> Path to WebDriverAgent.xcodeproj for ios-setup --start-wda.
|
|
163
|
-
--wda-bundle-id <id> Unique WebDriverAgentRunner bundle id for signing.
|
|
164
|
-
--team-id <id> Apple development team id for signing WebDriverAgentRunner.
|
|
165
|
-
--start-wda Let ios-setup run xcodebuild for WebDriverAgentRunner.
|
|
166
|
-
--adb <path> ADB executable path.
|
|
167
|
-
--adb-timeout-ms <ms> Timeout for ADB subprocesses.
|
|
168
|
-
--out-file <path> Screenshot output path.
|
|
169
|
-
--artifact-dir <path> Directory for generated screenshot/artifact defaults.
|
|
170
|
-
--apk-path <path> APK path used by install-apk.
|
|
171
|
-
--activity <name> Activity class for launch-activity or launch-app override.
|
|
172
|
-
--component <pkg/act> Explicit Android component for launch-activity.
|
|
173
|
-
--action <name> Intent action for launch-activity.
|
|
174
|
-
--category <name> Intent category for launch-activity; may be repeated.
|
|
175
|
-
--data <uri> Intent data URI for launch-activity.
|
|
176
|
-
--extra <key=value> String intent extra for launch-activity; may be repeated.
|
|
177
|
-
--text <text> Text used by Unicode-safe bridge input commands.
|
|
178
|
-
--value <text> Text value used by h5-input or flutter-h5-input.
|
|
179
|
-
--selector <css> CSS selector used by H5 commands.
|
|
180
|
-
--target-text <text> Text used by tap-text or wait-text.
|
|
181
|
-
--tap-x <n> X coordinate used by tap or Flutter input.
|
|
182
|
-
--tap-y <n> Y coordinate used by tap or Flutter input.
|
|
183
|
-
--accessibility-id <s> iOS accessibility identifier for element input.
|
|
184
|
-
--element-id <s> Existing WDA element id for element input.
|
|
185
|
-
--clear-first Clear the iOS input element before typing.
|
|
186
|
-
--start-x <n> Swipe start X coordinate.
|
|
187
|
-
--start-y <n> Swipe start Y coordinate.
|
|
188
|
-
--end-x <n> Swipe end X coordinate.
|
|
189
|
-
--end-y <n> Swipe end Y coordinate.
|
|
190
|
-
--key-code <n> Android key code used by keyevent.
|
|
191
|
-
--require-text <text> Extra text that must be present for wait-text.
|
|
192
|
-
--absent-text <text> Text that must be absent for wait-text.
|
|
193
|
-
--require-activity <s> Activity substring that must match for wait-text.
|
|
194
|
-
--timeout-sec <n> Wait timeout for wait-text or H5 waits.
|
|
195
|
-
--interval-ms <ms> Polling interval for wait/dialog/keyboard helpers.
|
|
196
|
-
--hide-keyboard Hide the soft keyboard after input-text.
|
|
197
|
-
--no-auto-hide-keyboard Disable keyboard-risk guard before lower-screen taps.
|
|
198
|
-
--allow-downgrade Pass -d to adb install.
|
|
199
|
-
--install-timeout-ms <ms> Timeout for the adb install subprocess.
|
|
200
|
-
--installer-timeout-ms <ms> Timeout for installer UI confirmation handling.
|
|
201
|
-
--webview-port <port> Local port used for WebView DevTools forwarding.
|
|
202
|
-
--socket-name <name> Explicit webview_devtools_remote socket name.
|
|
203
|
-
--target-id <id> Explicit CDP target/page id.
|
|
204
|
-
--page-url-filter <s> Prefer a WebView page whose URL contains this string.
|
|
205
|
-
--duration-ms <ms> CDP capture duration. Defaults to 3000 ms.
|
|
206
|
-
--script <js> JavaScript expression to evaluate after CDP attach.
|
|
207
|
-
--include-response-body Include response bodies when CDP exposes them.
|
|
208
|
-
--keep-forward Keep WebView DevTools ADB forward after command exits.
|
|
209
|
-
--url-filter <s> Keep network records whose URL contains this string.
|
|
210
|
-
--method <s> Keep network records with this HTTP method.
|
|
211
|
-
--status-code <n> Keep network records with this HTTP status.
|
|
212
|
-
--no-bodies Omit requestBody/responseBody from network output.
|
|
213
|
-
--body-max-bytes <n> Truncate network request/response bodies.
|
|
214
|
-
--since-id <n> Incremental capture cursor for logs/network/state/events.
|
|
215
|
-
--since-ms <n> Incremental capture timestamp for logs/network/state/events.
|
|
216
|
-
--limit <n> Limit capture records.
|
|
217
|
-
--full Return full raw status output, including Flutter layout dumps.
|
|
218
|
-
--compact Return compact tree output for tree/uia-tree.
|
|
219
|
-
--text-filter <s> Keep compact tree nodes whose text/description contains this.
|
|
220
|
-
--resource-id-filter <s> Keep compact tree nodes whose resource id/name contains this.
|
|
221
|
-
--class-filter <s> Keep compact tree nodes whose class contains this.
|
|
222
|
-
--visible-only Keep only visible/in-viewport compact tree nodes.
|
|
223
|
-
--max-nodes <n> Maximum compact tree nodes to return.
|
|
224
|
-
--max-depth <n> Maximum compact tree depth to scan.
|
|
225
|
-
--payload <json> Raw Flutter action payload for flutter-action.
|
|
226
|
-
--initial-route <path> Initial route for launch-flutter.
|
|
227
|
-
--delta <n> Scroll delta for Flutter/H5 scroll helpers.
|
|
228
|
-
--delta-x <n> Horizontal scroll delta.
|
|
229
|
-
--delta-y <n> Vertical scroll delta.
|
|
230
|
-
--max-swipes <n> Maximum swipes for scroll-flutter by text.
|
|
231
|
-
--permission <name> Android permission for permission-* commands.
|
|
232
|
-
--op <name> App-op name for appops-set.
|
|
233
|
-
--mode <mode> App-op mode for appops-set.
|
|
234
|
-
--pid <pid|current> PID filter for logcat, or explicit process for freeze/thaw.
|
|
235
|
-
--app-pid Filter logcat by current app pid.
|
|
236
|
-
--tag <tag[,tag]> Logcat tag filter.
|
|
237
|
-
--level <level> Minimum logcat level.
|
|
238
|
-
--grep <text> Logcat substring filter.
|
|
239
|
-
--lines <n> Logcat tail line count.
|
|
240
|
-
--since <time> Passed to adb logcat -T.
|
|
241
|
-
--follow Follow live logcat for a bounded duration.
|
|
242
|
-
--clear Clear logcat before reading.
|
|
243
|
-
--force Force hide-keyboard dismiss attempts.
|
|
244
|
-
--exact Use exact text matching where supported.
|
|
245
|
-
--resource-id <id> Permission dialog resource id.
|
|
246
|
-
--button-text <text> Permission dialog button text.
|
|
247
|
-
--attempts <n> Permission dialog tap attempts.
|
|
248
|
-
--streaming Use streaming APK install.
|
|
249
|
-
--skip-flutter-launch Skip Flutter launch during smoke.
|
|
250
|
-
--help Show this help without touching ADB or the device.`;
|
|
251
|
-
|
|
252
|
-
if (require.main === module) {
|
|
253
|
-
main().catch((error) => {
|
|
254
|
-
console.error(error.stack || error.message || String(error));
|
|
255
|
-
process.exitCode = 1;
|
|
256
|
-
});
|
|
257
|
-
}
|
|
20
|
+
Use --help <command> to inspect its JSON input schema.
|
|
21
|
+
CLI flags use kebab-case, for example --package-name, --tap-x, --timeout-ms.
|
|
22
|
+
Objects, arrays, nullable objects and Script decisions use JSON flag values.
|
|
23
|
+
Only --category and --extra repeat as individual strings.
|
|
24
|
+
Results are JSON: {kind: "json"|"text"|"bytes", value, history?}.
|
|
25
|
+
Bytes are base64; value.ok:false exits with code 1. Help is plain text.
|
|
26
|
+
Relative file paths use the calling directory. Script cwd defaults to it.
|
|
27
|
+
Example: ai-app-bridge script --operation start --script '{"schemaVersion":"aab.code-script/v1","language":"javascript","sourcePath":"./regression.js"}'
|
|
28
|
+
`;
|
|
258
29
|
|
|
259
30
|
async function main() {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const ctx = {
|
|
273
|
-
adb: options.adb || defaults.adb,
|
|
274
|
-
adbTimeoutMs: Number(options.adbTimeoutMs || defaults.adbTimeoutMs),
|
|
275
|
-
serial: options.serial || defaults.serial,
|
|
276
|
-
port: Number(options.port || defaults.port),
|
|
277
|
-
explicitPort: options.port !== undefined,
|
|
278
|
-
packageName: options.packageName || defaults.packageName,
|
|
279
|
-
explicitPackageName: options.packageName !== undefined,
|
|
280
|
-
nativeActivity: options.nativeActivity || defaults.nativeActivity,
|
|
281
|
-
flutterActivity: options.flutterActivity || defaults.flutterActivity,
|
|
282
|
-
};
|
|
283
|
-
|
|
284
|
-
const result = await runCommand(command || options.command || 'status', options, ctx);
|
|
285
|
-
writeCliResult(result);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
function writeCliResult(result) {
|
|
289
|
-
if (Buffer.isBuffer(result)) {
|
|
290
|
-
process.stdout.write(result);
|
|
291
|
-
return;
|
|
292
|
-
}
|
|
293
|
-
if (typeof result === 'string') {
|
|
294
|
-
process.stdout.write(result);
|
|
295
|
-
if (!result.endsWith('\n')) process.stdout.write('\n');
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function isIOSCommand(command) {
|
|
302
|
-
return String(command || '').startsWith('ios-');
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
async function runCommand(command, options, ctx) {
|
|
306
|
-
switch (command) {
|
|
307
|
-
case 'forward':
|
|
308
|
-
return ensureForward(ctx);
|
|
309
|
-
case 'remove-forward':
|
|
310
|
-
await adb(ctx, ['forward', '--remove', `tcp:${ctx.port}`]);
|
|
311
|
-
return { ok: true, removed: `tcp:${ctx.port}` };
|
|
312
|
-
case 'status':
|
|
313
|
-
return bridgeStatus(ctx, options);
|
|
314
|
-
case 'tree':
|
|
315
|
-
return bridgeTree(ctx, options);
|
|
316
|
-
case 'flutter-tree': {
|
|
317
|
-
const status = await bridgeGet(ctx, '/v1/status');
|
|
318
|
-
return status.flutter?.layout || null;
|
|
319
|
-
}
|
|
320
|
-
case 'flutter-nodes':
|
|
321
|
-
return flutterNodes(ctx);
|
|
322
|
-
case 'tap-flutter-text':
|
|
323
|
-
return flutterAction(ctx, { action: 'tapText', text: requiredString(options.targetText, 'targetText') });
|
|
324
|
-
case 'input-flutter-text': {
|
|
325
|
-
const result = await flutterAction(ctx, {
|
|
326
|
-
action: 'inputText',
|
|
327
|
-
text: requiredString(options.text, 'text'),
|
|
328
|
-
...(options.tapX !== undefined && options.tapY !== undefined ? { x: Number(options.tapX), y: Number(options.tapY) } : {}),
|
|
329
|
-
});
|
|
330
|
-
if (booleanOption(options.hideKeyboard)) {
|
|
331
|
-
result.keyboard = await hideKeyboard(ctx, options);
|
|
332
|
-
}
|
|
333
|
-
return result;
|
|
31
|
+
let command;
|
|
32
|
+
const connection = new AbortController();
|
|
33
|
+
const disconnect = () => connection.abort();
|
|
34
|
+
process.once('SIGINT', disconnect);
|
|
35
|
+
process.once('SIGTERM', disconnect);
|
|
36
|
+
try {
|
|
37
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
38
|
+
command = parsed.command;
|
|
39
|
+
if (parsed.options.help || command === 'help') {
|
|
40
|
+
const name = command === 'help' ? '' : command;
|
|
41
|
+
process.stdout.write(name ? `${JSON.stringify(commandSchema(name), null, 2)}\n` : `${helpText}\n`);
|
|
42
|
+
return;
|
|
334
43
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
case 'state':
|
|
346
|
-
return bridgeGet(ctx, withQuery('/v1/state', captureQuery(options)));
|
|
347
|
-
case 'events':
|
|
348
|
-
return bridgeGet(ctx, withQuery('/v1/events', captureQuery(options)));
|
|
349
|
-
case 'freeze-app':
|
|
350
|
-
return freezeApp(ctx, options);
|
|
351
|
-
case 'thaw-app':
|
|
352
|
-
return thawApp(ctx, options);
|
|
353
|
-
case 'h5-dom':
|
|
354
|
-
return bridgeGet(ctx, '/v1/h5/dom');
|
|
355
|
-
case 'h5-eval':
|
|
356
|
-
return bridgePost(ctx, '/v1/h5/eval', { script: requiredString(options.script, 'script') });
|
|
357
|
-
case 'h5-click':
|
|
358
|
-
return h5Click(ctx, options);
|
|
359
|
-
case 'h5-input':
|
|
360
|
-
return h5Input(ctx, options);
|
|
361
|
-
case 'h5-wait':
|
|
362
|
-
return h5Wait(ctx, options);
|
|
363
|
-
case 'h5-scroll':
|
|
364
|
-
return h5Scroll(ctx, options);
|
|
365
|
-
case 'flutter-h5-dom':
|
|
366
|
-
return flutterH5Dom(ctx);
|
|
367
|
-
case 'flutter-h5-eval':
|
|
368
|
-
return flutterH5Eval(ctx, { script: requiredString(options.script, 'script') });
|
|
369
|
-
case 'flutter-h5-click':
|
|
370
|
-
return flutterH5Click(ctx, options);
|
|
371
|
-
case 'flutter-h5-input':
|
|
372
|
-
return flutterH5Input(ctx, options);
|
|
373
|
-
case 'flutter-h5-wait':
|
|
374
|
-
return flutterH5Wait(ctx, options);
|
|
375
|
-
case 'flutter-h5-scroll':
|
|
376
|
-
return flutterH5Scroll(ctx, options);
|
|
377
|
-
case 'uia-tree':
|
|
378
|
-
return uiaTreeCommand(ctx, options);
|
|
379
|
-
case 'screenshot':
|
|
380
|
-
return screenshot(ctx, screenshotOutputPath(options, 'ai_app_bridge_screenshot'), options);
|
|
381
|
-
case 'tap':
|
|
382
|
-
return tap(ctx, requiredNumber(options.tapX, 'tapX'), requiredNumber(options.tapY, 'tapY'));
|
|
383
|
-
case 'tap-text':
|
|
384
|
-
return tapText(ctx, requiredString(options.targetText, 'targetText'), options);
|
|
385
|
-
case 'wait-text':
|
|
386
|
-
return waitText(ctx, requiredString(options.targetText, 'targetText'), options);
|
|
387
|
-
case 'input-text':
|
|
388
|
-
return inputText(ctx, requiredString(options.text, 'text'), options);
|
|
389
|
-
case 'keyboard-state':
|
|
390
|
-
return keyboardState(ctx);
|
|
391
|
-
case 'hide-keyboard':
|
|
392
|
-
return hideKeyboard(ctx, options);
|
|
393
|
-
case 'install-apk':
|
|
394
|
-
return installApk(ctx, options);
|
|
395
|
-
case 'clear-app-data':
|
|
396
|
-
return clearAppData(ctx);
|
|
397
|
-
case 'webview-pages':
|
|
398
|
-
return webviewPages(ctx, options);
|
|
399
|
-
case 'webview-network':
|
|
400
|
-
return webviewCdpCapture(ctx, { ...options, captureNetwork: true, captureConsole: true });
|
|
401
|
-
case 'webview-console':
|
|
402
|
-
return webviewCdpCapture(ctx, { ...options, captureNetwork: false, captureConsole: true });
|
|
403
|
-
case 'swipe':
|
|
404
|
-
return swipe(
|
|
405
|
-
ctx,
|
|
406
|
-
requiredNumber(options.startX, 'startX'),
|
|
407
|
-
requiredNumber(options.startY, 'startY'),
|
|
408
|
-
requiredNumber(options.endX, 'endX'),
|
|
409
|
-
requiredNumber(options.endY, 'endY'),
|
|
410
|
-
Number(options.durationMs || 300),
|
|
411
|
-
);
|
|
412
|
-
case 'keyevent':
|
|
413
|
-
return keyevent(ctx, Number(options.keyCode || 4));
|
|
414
|
-
case 'logcat':
|
|
415
|
-
return logcat(ctx, options);
|
|
416
|
-
case 'permission-state':
|
|
417
|
-
return permissionState(ctx, requiredString(options.permission, 'permission'));
|
|
418
|
-
case 'permission-grant':
|
|
419
|
-
return permissionGrant(ctx, requiredString(options.permission, 'permission'));
|
|
420
|
-
case 'permission-revoke':
|
|
421
|
-
return permissionRevoke(ctx, requiredString(options.permission, 'permission'));
|
|
422
|
-
case 'appops-set':
|
|
423
|
-
return appopsSet(ctx, requiredString(options.op, 'op'), requiredString(options.mode, 'mode'));
|
|
424
|
-
case 'tap-uia-text':
|
|
425
|
-
return tapUiaText(ctx, requiredString(options.targetText, 'targetText'), options);
|
|
426
|
-
case 'permission-dialog':
|
|
427
|
-
return permissionDialog(ctx, options);
|
|
428
|
-
case 'launch-app':
|
|
429
|
-
return launchApp(ctx, options);
|
|
430
|
-
case 'launch-activity':
|
|
431
|
-
return launchActivity(ctx, options);
|
|
432
|
-
case 'launch-native-test':
|
|
433
|
-
return launchNativeTest(ctx);
|
|
434
|
-
case 'launch-flutter':
|
|
435
|
-
return launchFlutter(ctx, options.initialRoute || '');
|
|
436
|
-
case 'smoke':
|
|
437
|
-
return smoke(ctx, options);
|
|
438
|
-
default:
|
|
439
|
-
throw new Error(`unknown command: ${command}`);
|
|
44
|
+
command ||= 'status';
|
|
45
|
+
const reply = await runtime.run({ command, arguments: parseCliOptions(command, parsed.options) }, { signal: connection.signal });
|
|
46
|
+
process.stdout.write(`${JSON.stringify(encodeReply(reply), null, 2)}\n`);
|
|
47
|
+
if (reply.value?.ok === false) process.exitCode = 1;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
process.stdout.write(`${JSON.stringify(encodeReply({ value: commandFailure(error, command) }), null, 2)}\n`);
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
} finally {
|
|
52
|
+
process.removeListener('SIGINT', disconnect);
|
|
53
|
+
process.removeListener('SIGTERM', disconnect);
|
|
440
54
|
}
|
|
441
55
|
}
|
|
442
56
|
|
|
@@ -450,12 +64,15 @@ function parseArgs(argv) {
|
|
|
450
64
|
continue;
|
|
451
65
|
}
|
|
452
66
|
if (!arg.startsWith('--')) {
|
|
453
|
-
|
|
67
|
+
throw new CommandError('unexpected_argument', `Unexpected positional argument: ${arg}. Supply values through named flags.`, { field: `argv[${index}]` });
|
|
454
68
|
}
|
|
455
69
|
const rawName = arg.slice(2);
|
|
70
|
+
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(rawName)) {
|
|
71
|
+
throw new CommandError('invalid_argument', `Invalid CLI flag: ${arg}. Use --kebab-case flags followed by separate value tokens.`, { field: `argv[${index}]` });
|
|
72
|
+
}
|
|
456
73
|
const name = rawName.replace(/-([a-z])/g, (_, value) => value.toUpperCase());
|
|
457
74
|
const next = argv[index + 1];
|
|
458
|
-
if (next === undefined || next.startsWith('--')) {
|
|
75
|
+
if (name === 'help' || next === undefined || next.startsWith('--')) {
|
|
459
76
|
appendOption(options, name, true);
|
|
460
77
|
continue;
|
|
461
78
|
}
|
|
@@ -471,4081 +88,11 @@ function appendOption(options, name, value) {
|
|
|
471
88
|
options[name].push(value);
|
|
472
89
|
return;
|
|
473
90
|
}
|
|
474
|
-
options
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
async function adb(ctx, args, { binary = false } = {}) {
|
|
478
|
-
const allArgs = adbArgs(ctx, args);
|
|
479
|
-
return new Promise((resolve, reject) => {
|
|
480
|
-
execFile(ctx.adb, allArgs, {
|
|
481
|
-
encoding: binary ? 'buffer' : 'utf8',
|
|
482
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
483
|
-
timeout: ctx.adbTimeoutMs || defaults.adbTimeoutMs,
|
|
484
|
-
windowsHide: true,
|
|
485
|
-
}, (error, stdout, stderr) => {
|
|
486
|
-
if (error) {
|
|
487
|
-
if (error.killed || error.signal) {
|
|
488
|
-
error.message = `adb timed out after ${ctx.adbTimeoutMs || defaults.adbTimeoutMs}ms: ${ctx.adb} ${allArgs.join(' ')}`;
|
|
489
|
-
}
|
|
490
|
-
error.message = `${error.message}\n${stderr || ''}`;
|
|
491
|
-
reject(error);
|
|
492
|
-
return;
|
|
493
|
-
}
|
|
494
|
-
resolve({ stdout, stderr });
|
|
495
|
-
});
|
|
496
|
-
});
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
async function adbBinaryToFile(ctx, args, outFile) {
|
|
500
|
-
const allArgs = adbArgs(ctx, args);
|
|
501
|
-
await fs.promises.mkdir(path.dirname(path.resolve(outFile)), { recursive: true });
|
|
502
|
-
return new Promise((resolve, reject) => {
|
|
503
|
-
const child = spawn(ctx.adb, allArgs, { windowsHide: true });
|
|
504
|
-
const output = fs.createWriteStream(outFile);
|
|
505
|
-
let stderr = '';
|
|
506
|
-
const timeout = setTimeout(() => {
|
|
507
|
-
child.kill();
|
|
508
|
-
}, ctx.adbTimeoutMs || defaults.adbTimeoutMs);
|
|
509
|
-
child.stdout.pipe(output);
|
|
510
|
-
child.stderr.on('data', (chunk) => {
|
|
511
|
-
stderr += chunk.toString();
|
|
512
|
-
});
|
|
513
|
-
child.on('error', reject);
|
|
514
|
-
child.on('close', (code) => {
|
|
515
|
-
clearTimeout(timeout);
|
|
516
|
-
output.close(() => {
|
|
517
|
-
if (code !== 0) {
|
|
518
|
-
reject(new Error(`adb failed with exit code ${code}: ${stderr}`));
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
resolve(path.resolve(outFile));
|
|
522
|
-
});
|
|
523
|
-
});
|
|
524
|
-
});
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
function adbArgs(ctx, args) {
|
|
528
|
-
const allArgs = [];
|
|
529
|
-
if (ctx.serial) allArgs.push('-s', ctx.serial);
|
|
530
|
-
allArgs.push(...args);
|
|
531
|
-
return allArgs;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
async function installApk(ctx, options) {
|
|
535
|
-
const apkPath = path.resolve(requiredString(options.apkPath || options.apk, 'apkPath'));
|
|
536
|
-
if (!fs.existsSync(apkPath)) {
|
|
537
|
-
throw new Error(`apkPath does not exist: ${apkPath}`);
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
const packageBefore = await safePackageInstallState(ctx);
|
|
541
|
-
const installMode = packageBefore.known
|
|
542
|
-
? (packageBefore.installed ? 'reinstall' : 'new_install')
|
|
543
|
-
: 'unknown_without_package_name';
|
|
544
|
-
const installArgs = ['install'];
|
|
545
|
-
if (!booleanOption(options.streaming)) {
|
|
546
|
-
installArgs.push('--no-streaming');
|
|
547
|
-
}
|
|
548
|
-
installArgs.push('-r');
|
|
549
|
-
if (booleanOption(options.allowDowngrade)) {
|
|
550
|
-
installArgs.push('-d');
|
|
551
|
-
}
|
|
552
|
-
installArgs.push(apkPath);
|
|
553
|
-
|
|
554
|
-
const installTimeoutMs = Number(options.installTimeoutMs || 180000);
|
|
555
|
-
const installerTimeoutMs = Number(options.installerTimeoutMs || 90000);
|
|
556
|
-
const intervalMs = Number(options.intervalMs || 700);
|
|
557
|
-
const child = spawn(ctx.adb, adbArgs(ctx, installArgs), { windowsHide: true });
|
|
558
|
-
let stdout = '';
|
|
559
|
-
let stderr = '';
|
|
560
|
-
let processDone = false;
|
|
561
|
-
let processResult = null;
|
|
562
|
-
|
|
563
|
-
const processPromise = new Promise((resolve) => {
|
|
564
|
-
child.stdout.on('data', (chunk) => {
|
|
565
|
-
stdout += chunk.toString();
|
|
566
|
-
});
|
|
567
|
-
child.stderr.on('data', (chunk) => {
|
|
568
|
-
stderr += chunk.toString();
|
|
569
|
-
});
|
|
570
|
-
child.on('error', (error) => {
|
|
571
|
-
processDone = true;
|
|
572
|
-
processResult = { code: null, error: firstErrorLine(error) };
|
|
573
|
-
resolve(processResult);
|
|
574
|
-
});
|
|
575
|
-
child.on('close', (code, signal) => {
|
|
576
|
-
processDone = true;
|
|
577
|
-
processResult = { code, signal, error: null };
|
|
578
|
-
resolve(processResult);
|
|
579
|
-
});
|
|
580
|
-
});
|
|
581
|
-
|
|
582
|
-
const installerActions = [];
|
|
583
|
-
const installDeadline = Date.now() + installTimeoutMs;
|
|
584
|
-
while (!processDone && Date.now() < installDeadline) {
|
|
585
|
-
const action = await installerAssistOnce(ctx, { phase: 'install-pending' });
|
|
586
|
-
if (action.action === 'tap') {
|
|
587
|
-
installerActions.push(action);
|
|
588
|
-
}
|
|
589
|
-
await sleep(intervalMs);
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
let timedOut = false;
|
|
593
|
-
if (!processDone) {
|
|
594
|
-
timedOut = true;
|
|
595
|
-
child.kill();
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
processResult = processResult || (await processPromise);
|
|
599
|
-
const postInstallActions = await assistInstallerScreens(ctx, {
|
|
600
|
-
phase: 'post-install',
|
|
601
|
-
timeoutMs: installerTimeoutMs,
|
|
602
|
-
intervalMs,
|
|
603
|
-
});
|
|
604
|
-
const packageAfter = await safePackageInstallState(ctx);
|
|
605
|
-
const output = `${stdout}\n${stderr}`.trim();
|
|
606
|
-
const adbSuccess = processResult.code === 0 && /Success/i.test(output);
|
|
607
|
-
const packageVerified = !packageAfter.known || packageAfter.installed;
|
|
608
|
-
|
|
609
|
-
return {
|
|
610
|
-
ok: !timedOut && adbSuccess && packageVerified,
|
|
611
|
-
action: 'install-apk',
|
|
612
|
-
transport: 'adb',
|
|
613
|
-
apkPath,
|
|
614
|
-
packageName: ctx.explicitPackageName ? ctx.packageName : null,
|
|
615
|
-
installMode,
|
|
616
|
-
installedBefore: packageBefore,
|
|
617
|
-
installedAfter: packageAfter,
|
|
618
|
-
process: {
|
|
619
|
-
...processResult,
|
|
620
|
-
timedOut,
|
|
621
|
-
stdout: stdout.trim(),
|
|
622
|
-
stderr: stderr.trim(),
|
|
623
|
-
},
|
|
624
|
-
installerActions,
|
|
625
|
-
postInstallActions,
|
|
626
|
-
error: timedOut ? 'install_timeout' : (adbSuccess ? null : 'adb_install_failed'),
|
|
627
|
-
};
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
async function safePackageInstallState(ctx) {
|
|
631
|
-
if (!ctx.explicitPackageName) {
|
|
632
|
-
return { known: false, reason: 'package_name_not_provided' };
|
|
633
|
-
}
|
|
634
|
-
try {
|
|
635
|
-
const result = await adb(ctx, ['shell', 'pm', 'path', ctx.packageName]);
|
|
636
|
-
const paths = result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
637
|
-
return {
|
|
638
|
-
known: true,
|
|
639
|
-
installed: paths.some((line) => line.startsWith('package:')),
|
|
640
|
-
paths,
|
|
641
|
-
};
|
|
642
|
-
} catch (error) {
|
|
643
|
-
return {
|
|
644
|
-
known: true,
|
|
645
|
-
installed: false,
|
|
646
|
-
error: firstErrorLine(error),
|
|
647
|
-
};
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
async function assistInstallerScreens(ctx, options) {
|
|
652
|
-
const timeoutMs = Number(options.timeoutMs || 90000);
|
|
653
|
-
const intervalMs = Number(options.intervalMs || 700);
|
|
654
|
-
const deadline = Date.now() + timeoutMs;
|
|
655
|
-
const actions = [];
|
|
656
|
-
let quietPolls = 0;
|
|
657
|
-
while (Date.now() < deadline) {
|
|
658
|
-
const action = await installerAssistOnce(ctx, { phase: options.phase || 'installer' });
|
|
659
|
-
if (action.action === 'tap') {
|
|
660
|
-
actions.push(action);
|
|
661
|
-
quietPolls = 0;
|
|
662
|
-
await sleep(intervalMs);
|
|
663
|
-
continue;
|
|
664
|
-
}
|
|
665
|
-
if (action.reason === 'not_installer_surface' || action.reason === 'installer_finish_surface_no_action') {
|
|
666
|
-
quietPolls += 1;
|
|
667
|
-
if (quietPolls >= 2) break;
|
|
668
|
-
}
|
|
669
|
-
await sleep(intervalMs);
|
|
670
|
-
}
|
|
671
|
-
return actions;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
async function installerAssistOnce(ctx, options = {}) {
|
|
675
|
-
let foreground;
|
|
676
|
-
let xml;
|
|
677
|
-
try {
|
|
678
|
-
foreground = await foregroundWindow(ctx);
|
|
679
|
-
xml = await uiaTree(ctx);
|
|
680
|
-
} catch (error) {
|
|
681
|
-
return {
|
|
682
|
-
ok: false,
|
|
683
|
-
action: 'none',
|
|
684
|
-
phase: options.phase || 'installer',
|
|
685
|
-
reason: 'probe_failed',
|
|
686
|
-
message: firstErrorLine(error),
|
|
687
|
-
};
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
const surface = classifyInstallerSurface(foreground, xml);
|
|
691
|
-
if (!surface.installer) {
|
|
692
|
-
return {
|
|
693
|
-
ok: true,
|
|
694
|
-
action: 'none',
|
|
695
|
-
phase: options.phase || 'installer',
|
|
696
|
-
reason: 'not_installer_surface',
|
|
697
|
-
foreground,
|
|
698
|
-
surface,
|
|
699
|
-
};
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
if (ctx.explicitPackageName && !surface.finish) {
|
|
703
|
-
const packageState = await safePackageInstallState(ctx);
|
|
704
|
-
if (shouldSkipInstallerTapForInstalledPackage({
|
|
705
|
-
phase: options.phase || 'installer',
|
|
706
|
-
packageState,
|
|
707
|
-
})) {
|
|
708
|
-
return {
|
|
709
|
-
ok: true,
|
|
710
|
-
action: 'none',
|
|
711
|
-
phase: options.phase || 'installer',
|
|
712
|
-
reason: 'target_package_already_installed',
|
|
713
|
-
foreground,
|
|
714
|
-
surface,
|
|
715
|
-
packageState,
|
|
716
|
-
};
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
const node = findUiaNodeByAny(xml, {
|
|
721
|
-
texts: installerButtonTextsForSurface(surface),
|
|
722
|
-
exact: true,
|
|
723
|
-
requireClickable: true,
|
|
724
|
-
});
|
|
725
|
-
if (!node) {
|
|
726
|
-
return {
|
|
727
|
-
ok: surface.finish,
|
|
728
|
-
action: 'none',
|
|
729
|
-
phase: options.phase || 'installer',
|
|
730
|
-
reason: surface.finish ? 'installer_finish_surface_no_action' : 'installer_button_not_found',
|
|
731
|
-
foreground,
|
|
732
|
-
surface,
|
|
733
|
-
};
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
const x = Math.round((node.left + node.right) / 2);
|
|
737
|
-
const y = Math.round((node.top + node.bottom) / 2);
|
|
738
|
-
await tap(ctx, x, y);
|
|
739
|
-
return {
|
|
740
|
-
ok: true,
|
|
741
|
-
action: 'tap',
|
|
742
|
-
phase: options.phase || 'installer',
|
|
743
|
-
source: 'uiautomator',
|
|
744
|
-
x,
|
|
745
|
-
y,
|
|
746
|
-
foreground,
|
|
747
|
-
surface,
|
|
748
|
-
matched: node.matched,
|
|
749
|
-
};
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
function isLikelyInstallerSurface(foreground, xml) {
|
|
753
|
-
return classifyInstallerSurface(foreground, xml).installer;
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
function classifyInstallerSurface(foreground, xml) {
|
|
757
|
-
const text = String(xml || '');
|
|
758
|
-
const foregroundPackage = String(foreground?.packageName || '');
|
|
759
|
-
const foregroundActivity = String(foreground?.activity || '');
|
|
760
|
-
const marketPackages = [
|
|
761
|
-
'com.heytap.market',
|
|
762
|
-
'com.oppo.market',
|
|
763
|
-
'com.android.vending',
|
|
764
|
-
];
|
|
765
|
-
const knownInstallerPackages = [
|
|
766
|
-
'com.android.packageinstaller',
|
|
767
|
-
'com.google.android.packageinstaller',
|
|
768
|
-
'com.miui.packageinstaller',
|
|
769
|
-
'com.samsung.android.packageinstaller',
|
|
770
|
-
'com.oplus.appdetail',
|
|
771
|
-
'com.coloros.securitypermission',
|
|
772
|
-
'packageinstaller',
|
|
773
|
-
];
|
|
774
|
-
const hasInstallerPackage = knownInstallerPackages.some((value) => {
|
|
775
|
-
return foregroundPackage.includes(value) || text.includes(`package="${value}`);
|
|
776
|
-
});
|
|
777
|
-
const hasMarketPackage = marketPackages.some((value) => foregroundPackage.includes(value) || text.includes(`package="${value}`));
|
|
778
|
-
const finish = /finish/i.test(foregroundActivity) ||
|
|
779
|
-
text.includes('安装完成') ||
|
|
780
|
-
text.includes('已安装') ||
|
|
781
|
-
text.includes('Install complete') ||
|
|
782
|
-
text.includes('App installed');
|
|
783
|
-
if (hasInstallerPackage) {
|
|
784
|
-
return {
|
|
785
|
-
installer: true,
|
|
786
|
-
finish,
|
|
787
|
-
market: false,
|
|
788
|
-
source: 'installer_package',
|
|
789
|
-
foregroundPackage,
|
|
790
|
-
foregroundActivity,
|
|
791
|
-
};
|
|
792
|
-
}
|
|
793
|
-
if (hasMarketPackage) {
|
|
794
|
-
return {
|
|
795
|
-
installer: false,
|
|
796
|
-
finish: false,
|
|
797
|
-
market: true,
|
|
798
|
-
source: 'market_package',
|
|
799
|
-
foregroundPackage,
|
|
800
|
-
foregroundActivity,
|
|
801
|
-
};
|
|
802
|
-
}
|
|
803
|
-
const hasRiskKeyword = [
|
|
804
|
-
'检测结果',
|
|
805
|
-
'未知来源',
|
|
806
|
-
'未知应用',
|
|
807
|
-
'敏感权限',
|
|
808
|
-
'安全扫描',
|
|
809
|
-
'Install unknown apps',
|
|
810
|
-
'Package installer',
|
|
811
|
-
].some((value) => text.includes(value));
|
|
812
|
-
return {
|
|
813
|
-
installer: hasRiskKeyword,
|
|
814
|
-
finish,
|
|
815
|
-
market: false,
|
|
816
|
-
source: hasRiskKeyword ? 'risk_keyword' : 'not_installer',
|
|
817
|
-
foregroundPackage,
|
|
818
|
-
foregroundActivity,
|
|
819
|
-
};
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
function installerButtonTextsForSurface(surface) {
|
|
823
|
-
if (surface?.finish) {
|
|
824
|
-
return [
|
|
825
|
-
'完成',
|
|
826
|
-
'确定',
|
|
827
|
-
'Done',
|
|
828
|
-
'OK',
|
|
829
|
-
];
|
|
830
|
-
}
|
|
831
|
-
const base = [
|
|
832
|
-
'继续安装',
|
|
833
|
-
'仍然安装',
|
|
834
|
-
'允许',
|
|
835
|
-
'确定',
|
|
836
|
-
'继续',
|
|
837
|
-
'下一步',
|
|
838
|
-
'Continue install',
|
|
839
|
-
'Install anyway',
|
|
840
|
-
'Allow',
|
|
841
|
-
'OK',
|
|
842
|
-
'Continue',
|
|
843
|
-
'Next',
|
|
844
|
-
];
|
|
845
|
-
if (!surface?.market) {
|
|
846
|
-
base.push('安装', 'Install');
|
|
847
|
-
}
|
|
848
|
-
return base;
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
function defaultInstallerButtonTexts() {
|
|
852
|
-
return installerButtonTextsForSurface({ finish: false, market: false });
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
function shouldSkipInstallerTapForInstalledPackage({ phase, packageState } = {}) {
|
|
856
|
-
if (!packageState?.installed) {
|
|
857
|
-
return false;
|
|
858
|
-
}
|
|
859
|
-
return phase !== 'install-pending';
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
async function ensureForward(ctx) {
|
|
863
|
-
const resolvedPort = await resolveDevicePort(ctx);
|
|
864
|
-
const devicePort = resolvedPort.port;
|
|
865
|
-
const hostPort = ctx.explicitPort ? ctx.port : devicePort;
|
|
866
|
-
ctx.devicePort = devicePort;
|
|
867
|
-
ctx.hostPort = hostPort;
|
|
868
|
-
ctx.devicePortSource = resolvedPort.source;
|
|
869
|
-
ctx.devicePortState = resolvedPort.state;
|
|
870
|
-
ctx.devicePortError = resolvedPort.error;
|
|
871
|
-
await adb(ctx, ['forward', `tcp:${hostPort}`, `tcp:${devicePort}`]);
|
|
872
|
-
return {
|
|
873
|
-
ok: true,
|
|
874
|
-
forward: `tcp:${hostPort} -> device tcp:${devicePort}`,
|
|
875
|
-
hostPort,
|
|
876
|
-
devicePort,
|
|
877
|
-
devicePortSource: resolvedPort.source,
|
|
878
|
-
};
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
async function resolveDevicePort(ctx) {
|
|
882
|
-
if (ctx.explicitPort) return { port: ctx.port, source: 'explicit-port' };
|
|
883
|
-
try {
|
|
884
|
-
const result = await adb(ctx, ['shell', 'run-as', ctx.packageName, 'cat', 'files/ai_app_bridge_port.json']);
|
|
885
|
-
const state = JSON.parse(result.stdout.trim());
|
|
886
|
-
const discoveredPort = Number(state.port);
|
|
887
|
-
if (state.ok === true && Number.isInteger(discoveredPort) && discoveredPort > 0) {
|
|
888
|
-
return { port: discoveredPort, source: 'package-port-file', state };
|
|
889
|
-
}
|
|
890
|
-
} catch (error) {
|
|
891
|
-
if (!shouldUseDefaultPortFallback(ctx)) {
|
|
892
|
-
ctx.devicePortSource = 'package-port-file';
|
|
893
|
-
ctx.devicePortError = firstErrorLine(error);
|
|
894
|
-
error.aiAppBridgePortDiscovery = true;
|
|
895
|
-
throw error;
|
|
896
|
-
}
|
|
897
|
-
return {
|
|
898
|
-
port: ctx.port,
|
|
899
|
-
source: 'default-port',
|
|
900
|
-
error: firstErrorLine(error),
|
|
901
|
-
};
|
|
902
|
-
}
|
|
903
|
-
if (!shouldUseDefaultPortFallback(ctx)) {
|
|
904
|
-
const error = new Error(`bridge port discovery failed for explicit package: ${ctx.packageName}`);
|
|
905
|
-
error.aiAppBridgePortDiscovery = true;
|
|
906
|
-
ctx.devicePortSource = 'package-port-file';
|
|
907
|
-
ctx.devicePortError = firstErrorLine(error);
|
|
908
|
-
throw error;
|
|
909
|
-
}
|
|
910
|
-
return { port: ctx.port, source: 'default-port' };
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
function shouldUseDefaultPortFallback(ctx) {
|
|
914
|
-
return !ctx.explicitPackageName;
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
async function bridgeStatus(ctx, options = {}) {
|
|
918
|
-
try {
|
|
919
|
-
const status = await bridgeGet(ctx, '/v1/status');
|
|
920
|
-
return booleanOption(options.full) ? status : compactStatus(status);
|
|
921
|
-
} catch (error) {
|
|
922
|
-
return buildBridgeFailureResult(ctx, 'status', '/v1/status', error);
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
function compactStatus(status) {
|
|
927
|
-
if (!status || typeof status !== 'object') return status;
|
|
928
|
-
const next = { ...status };
|
|
929
|
-
if (status.flutter && typeof status.flutter === 'object') {
|
|
930
|
-
next.flutter = compactFlutterStatus(status.flutter);
|
|
931
|
-
}
|
|
932
|
-
return next;
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
function compactFlutterStatus(flutterStatus) {
|
|
936
|
-
const next = { ...flutterStatus };
|
|
937
|
-
if (flutterStatus.layout && typeof flutterStatus.layout === 'object') {
|
|
938
|
-
next.layout = compactFlutterLayout(flutterStatus.layout);
|
|
939
|
-
}
|
|
940
|
-
return next;
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
function compactFlutterLayout(layout) {
|
|
944
|
-
return {
|
|
945
|
-
updatedAtMs: layout.updatedAtMs,
|
|
946
|
-
widgetInspector: layout.widgetInspector ? compactDiagnosticTreeNode(layout.widgetInspector) : undefined,
|
|
947
|
-
widgetDump: compactWidgetDump(layout.widgetDump),
|
|
948
|
-
semantics: layout.semantics ? compactSemantics(layout.semantics) : undefined,
|
|
949
|
-
operable: layout.operable ? compactFlutterOperable(layout.operable) : undefined,
|
|
950
|
-
};
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
function compactDiagnosticTreeNode(node) {
|
|
954
|
-
if (!node || typeof node !== 'object') return node;
|
|
955
|
-
return {
|
|
956
|
-
description: shortStatusString(node.description),
|
|
957
|
-
type: shortStatusString(node.type),
|
|
958
|
-
hasChildren: Boolean(node.hasChildren),
|
|
959
|
-
childCount: Array.isArray(node.children) ? node.children.length : undefined,
|
|
960
|
-
createdByLocalProject: Boolean(node.createdByLocalProject),
|
|
961
|
-
};
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
function compactWidgetDump(widgetDump) {
|
|
965
|
-
if (!widgetDump || typeof widgetDump !== 'object') return widgetDump;
|
|
966
|
-
return {
|
|
967
|
-
ok: widgetDump.ok,
|
|
968
|
-
error: widgetDump.error,
|
|
969
|
-
truncated: widgetDump.truncated,
|
|
970
|
-
length: widgetDump.length,
|
|
971
|
-
};
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
function compactSemantics(semantics) {
|
|
975
|
-
if (!semantics || typeof semantics !== 'object') return semantics;
|
|
976
|
-
return {
|
|
977
|
-
ok: semantics.ok,
|
|
978
|
-
error: semantics.error,
|
|
979
|
-
semanticsEnabled: semantics.semanticsEnabled,
|
|
980
|
-
nodeCount: semantics.nodeCount,
|
|
981
|
-
};
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
function compactFlutterOperable(operable) {
|
|
985
|
-
if (!operable || typeof operable !== 'object') return operable;
|
|
986
|
-
return {
|
|
987
|
-
ok: operable.ok,
|
|
988
|
-
error: operable.error,
|
|
989
|
-
count: operable.count,
|
|
990
|
-
visitedCount: operable.visitedCount,
|
|
991
|
-
textCount: operable.textCount,
|
|
992
|
-
actionCount: operable.actionCount,
|
|
993
|
-
sampleWidgetTypes: Array.isArray(operable.sampleWidgetTypes)
|
|
994
|
-
? operable.sampleWidgetTypes.slice(0, 20)
|
|
995
|
-
: operable.sampleWidgetTypes,
|
|
996
|
-
nodes: Array.isArray(operable.nodes)
|
|
997
|
-
? operable.nodes.slice(0, 12).map(compactFlutterOperableNode)
|
|
998
|
-
: operable.nodes,
|
|
999
|
-
truncated: operable.truncated,
|
|
1000
|
-
viewport: operable.viewport,
|
|
1001
|
-
updatedAtMs: operable.updatedAtMs,
|
|
1002
|
-
};
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
function compactFlutterOperableNode(node) {
|
|
1006
|
-
if (!node || typeof node !== 'object') return node;
|
|
1007
|
-
return {
|
|
1008
|
-
id: node.id,
|
|
1009
|
-
widgetType: shortStatusString(node.widgetType),
|
|
1010
|
-
text: shortStatusString(node.text),
|
|
1011
|
-
bounds: node.bounds,
|
|
1012
|
-
actions: node.actions,
|
|
1013
|
-
depth: node.depth,
|
|
1014
|
-
};
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
function shortStatusString(value, maxLength = 160) {
|
|
1018
|
-
if (value === undefined || value === null) return value;
|
|
1019
|
-
const text = String(value);
|
|
1020
|
-
return text.length > maxLength ? `${text.slice(0, maxLength)}...[truncated]` : text;
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
function buildBridgeFailureResult(ctx, command, requestPath, error) {
|
|
1024
|
-
const normalized = normalizeBridgeError(error);
|
|
1025
|
-
return {
|
|
1026
|
-
ok: false,
|
|
1027
|
-
command,
|
|
1028
|
-
requestPath,
|
|
1029
|
-
error: normalized.code,
|
|
1030
|
-
message: normalized.message,
|
|
1031
|
-
packageName: ctx.packageName,
|
|
1032
|
-
attempted: {
|
|
1033
|
-
host: '127.0.0.1',
|
|
1034
|
-
localPort: ctx.hostPort || ctx.port,
|
|
1035
|
-
devicePort: ctx.devicePort || ctx.port,
|
|
1036
|
-
devicePortSource: ctx.devicePortSource || (ctx.explicitPort ? 'explicit-port' : 'unknown'),
|
|
1037
|
-
portState: ctx.devicePortState || null,
|
|
1038
|
-
portDiscoveryError: ctx.devicePortError || null,
|
|
1039
|
-
url: bridgeUrl(ctx, requestPath),
|
|
1040
|
-
},
|
|
1041
|
-
suggestion: normalized.suggestion,
|
|
1042
|
-
};
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
function normalizeBridgeError(error) {
|
|
1046
|
-
const message = firstErrorLine(error);
|
|
1047
|
-
const code = error?.code || '';
|
|
1048
|
-
const lower = message.toLowerCase();
|
|
1049
|
-
if (
|
|
1050
|
-
code === 'ECONNRESET' ||
|
|
1051
|
-
lower.includes('socket hang up') ||
|
|
1052
|
-
lower.includes('connection reset') ||
|
|
1053
|
-
lower.includes('http timeout')
|
|
1054
|
-
) {
|
|
1055
|
-
return {
|
|
1056
|
-
code: 'bridge_not_ready',
|
|
1057
|
-
message,
|
|
1058
|
-
suggestion: 'Launch the target app, wait for the debug bridge to start, then retry status.',
|
|
1059
|
-
};
|
|
1060
|
-
}
|
|
1061
|
-
if (code === 'ECONNREFUSED' || lower.includes('econnrefused') || lower.includes('connection refused')) {
|
|
1062
|
-
return {
|
|
1063
|
-
code: 'bridge_connection_refused',
|
|
1064
|
-
message,
|
|
1065
|
-
suggestion: 'Confirm the target app is running and that the resolved bridge port belongs to this package.',
|
|
1066
|
-
};
|
|
1067
|
-
}
|
|
1068
|
-
if (lower.includes('adb timed out')) {
|
|
1069
|
-
return {
|
|
1070
|
-
code: 'adb_timeout',
|
|
1071
|
-
message,
|
|
1072
|
-
suggestion: 'Check the device state and retry; if the bridge port is known, pass --port to skip package port discovery.',
|
|
1073
|
-
};
|
|
1074
|
-
}
|
|
1075
|
-
if (error?.aiAppBridgePackageMismatch || lower.includes('bridge package mismatch')) {
|
|
1076
|
-
return {
|
|
1077
|
-
code: 'bridge_package_mismatch',
|
|
1078
|
-
message,
|
|
1079
|
-
suggestion: 'The resolved bridge port belongs to another package. Relaunch the target app and retry with the explicit packageName.',
|
|
1080
|
-
};
|
|
1081
|
-
}
|
|
1082
|
-
if (error?.aiAppBridgePortDiscovery || lower.includes('bridge port discovery failed') || lower.includes('run-as') || lower.includes('package not found')) {
|
|
1083
|
-
return {
|
|
1084
|
-
code: 'bridge_port_discovery_failed',
|
|
1085
|
-
message,
|
|
1086
|
-
suggestion: 'Install a debuggable build for the requested package or pass --port explicitly.',
|
|
1087
|
-
};
|
|
1088
|
-
}
|
|
1089
|
-
if (lower.includes('adb forward')) {
|
|
1090
|
-
return {
|
|
1091
|
-
code: 'bridge_forward_failed',
|
|
1092
|
-
message,
|
|
1093
|
-
suggestion: 'Remove stale adb forwards or pass a different --port for this target package.',
|
|
1094
|
-
};
|
|
1095
|
-
}
|
|
1096
|
-
return {
|
|
1097
|
-
code: 'bridge_request_failed',
|
|
1098
|
-
message,
|
|
1099
|
-
suggestion: 'Check that the device is connected, the target package is installed, and the app is foreground or recently launched.',
|
|
1100
|
-
};
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
function firstErrorLine(error) {
|
|
1104
|
-
return String(error?.message || error || 'unknown_error').split(/\r?\n/).find(Boolean) || 'unknown_error';
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
async function bridgeGet(ctx, requestPath) {
|
|
1108
|
-
await ensureForward(ctx);
|
|
1109
|
-
const body = await httpGet(bridgeUrl(ctx, requestPath));
|
|
1110
|
-
const payload = JSON.parse(body);
|
|
1111
|
-
verifyBridgeTargetPackage(ctx, payload, requestPath);
|
|
1112
|
-
return payload;
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
async function bridgePost(ctx, requestPath, payload) {
|
|
1116
|
-
await ensureForward(ctx);
|
|
1117
|
-
const body = await httpPost(bridgeUrl(ctx, requestPath), payload);
|
|
1118
|
-
const responsePayload = JSON.parse(body);
|
|
1119
|
-
verifyBridgeTargetPackage(ctx, responsePayload, requestPath);
|
|
1120
|
-
return responsePayload;
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
function bridgeUrl(ctx, requestPath) {
|
|
1124
|
-
return `http://127.0.0.1:${ctx.hostPort || ctx.port}${requestPath}`;
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
function verifyBridgeTargetPackage(ctx, payload, requestPath) {
|
|
1128
|
-
if (!ctx.explicitPackageName || !payload || typeof payload !== 'object') return;
|
|
1129
|
-
const responsePackageName = payload.app && typeof payload.app === 'object'
|
|
1130
|
-
? payload.app.packageName
|
|
1131
|
-
: undefined;
|
|
1132
|
-
if (!responsePackageName || responsePackageName === ctx.packageName) return;
|
|
1133
|
-
const error = new Error(`bridge package mismatch for ${requestPath}: expected ${ctx.packageName}, got ${responsePackageName}`);
|
|
1134
|
-
error.aiAppBridgePackageMismatch = true;
|
|
1135
|
-
error.expectedPackageName = ctx.packageName;
|
|
1136
|
-
error.actualPackageName = responsePackageName;
|
|
1137
|
-
throw error;
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
function httpGet(url) {
|
|
1141
|
-
return new Promise((resolve, reject) => {
|
|
1142
|
-
const request = http.get(url, { timeout: 10000 }, (response) => {
|
|
1143
|
-
let data = '';
|
|
1144
|
-
response.setEncoding('utf8');
|
|
1145
|
-
response.on('data', (chunk) => {
|
|
1146
|
-
data += chunk;
|
|
1147
|
-
});
|
|
1148
|
-
response.on('end', () => {
|
|
1149
|
-
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
1150
|
-
reject(new Error(`HTTP ${response.statusCode}: ${data}`));
|
|
1151
|
-
return;
|
|
1152
|
-
}
|
|
1153
|
-
resolve(data);
|
|
1154
|
-
});
|
|
1155
|
-
});
|
|
1156
|
-
request.on('timeout', () => {
|
|
1157
|
-
request.destroy(new Error(`HTTP timeout: ${url}`));
|
|
1158
|
-
});
|
|
1159
|
-
request.on('error', (error) => {
|
|
1160
|
-
error.url = url;
|
|
1161
|
-
reject(error);
|
|
1162
|
-
});
|
|
1163
|
-
});
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
function httpPost(url, payload) {
|
|
1167
|
-
const body = JSON.stringify(payload || {});
|
|
1168
|
-
return new Promise((resolve, reject) => {
|
|
1169
|
-
const request = http.request(url, {
|
|
1170
|
-
method: 'POST',
|
|
1171
|
-
timeout: 20000,
|
|
1172
|
-
headers: {
|
|
1173
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
1174
|
-
'Content-Length': Buffer.byteLength(body),
|
|
1175
|
-
},
|
|
1176
|
-
}, (response) => {
|
|
1177
|
-
let data = '';
|
|
1178
|
-
response.setEncoding('utf8');
|
|
1179
|
-
response.on('data', (chunk) => {
|
|
1180
|
-
data += chunk;
|
|
1181
|
-
});
|
|
1182
|
-
response.on('end', () => {
|
|
1183
|
-
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
1184
|
-
reject(new Error(`HTTP ${response.statusCode}: ${data}`));
|
|
1185
|
-
return;
|
|
1186
|
-
}
|
|
1187
|
-
resolve(data);
|
|
1188
|
-
});
|
|
1189
|
-
});
|
|
1190
|
-
request.on('timeout', () => {
|
|
1191
|
-
request.destroy(new Error(`HTTP timeout: ${url}`));
|
|
1192
|
-
});
|
|
1193
|
-
request.on('error', (error) => {
|
|
1194
|
-
error.url = url;
|
|
1195
|
-
reject(error);
|
|
1196
|
-
});
|
|
1197
|
-
request.write(body);
|
|
1198
|
-
request.end();
|
|
1199
|
-
});
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
function captureQuery(options) {
|
|
1203
|
-
return {
|
|
1204
|
-
sinceId: options.sinceId,
|
|
1205
|
-
sinceMs: options.sinceMs,
|
|
1206
|
-
limit: options.limit,
|
|
1207
|
-
};
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
async function networkRecords(ctx, options = {}) {
|
|
1211
|
-
const capture = await bridgeGet(ctx, withQuery('/v1/network', captureQuery(options)));
|
|
1212
|
-
return shapeNetworkCapture(capture, options);
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
function shapeNetworkCapture(capture, options = {}) {
|
|
1216
|
-
const sourceItems = Array.isArray(capture?.items) ? capture.items : [];
|
|
1217
|
-
const filteredItems = sourceItems.filter((item) => networkRecordMatches(item, options));
|
|
1218
|
-
const compact = booleanOption(options.compact);
|
|
1219
|
-
const shapedItems = filteredItems.map((item) => (
|
|
1220
|
-
compact ? compactNetworkRecord(item) : shapeNetworkRecord(item, options)
|
|
1221
|
-
));
|
|
1222
|
-
const result = {
|
|
1223
|
-
...capture,
|
|
1224
|
-
count: shapedItems.length,
|
|
1225
|
-
items: shapedItems,
|
|
1226
|
-
};
|
|
1227
|
-
if (filteredItems.length !== sourceItems.length) {
|
|
1228
|
-
result.sourceCount = sourceItems.length;
|
|
1229
|
-
}
|
|
1230
|
-
const filters = networkResultOptions(options);
|
|
1231
|
-
if (Object.keys(filters).length > 0) {
|
|
1232
|
-
result.options = filters;
|
|
1233
|
-
}
|
|
1234
|
-
return result;
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
function networkRecordMatches(record, options = {}) {
|
|
1238
|
-
const urlFilter = options.urlFilter ? String(options.urlFilter) : '';
|
|
1239
|
-
if (urlFilter && !networkRecordUrl(record).includes(urlFilter)) {
|
|
1240
|
-
return false;
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
const method = options.method ? String(options.method).toUpperCase() : '';
|
|
1244
|
-
if (method && networkRecordMethod(record).toUpperCase() !== method) {
|
|
1245
|
-
return false;
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
if (options.statusCode !== undefined && options.statusCode !== null && options.statusCode !== '') {
|
|
1249
|
-
const expectedStatus = Number(options.statusCode);
|
|
1250
|
-
if (!Number.isFinite(expectedStatus) || Number(networkRecordStatus(record)) !== expectedStatus) {
|
|
1251
|
-
return false;
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
|
-
return true;
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
function shapeNetworkRecord(record, options = {}) {
|
|
1259
|
-
const shaped = { ...record };
|
|
1260
|
-
const noBodies = booleanOption(options.noBodies);
|
|
1261
|
-
for (const key of ['requestBody', 'responseBody']) {
|
|
1262
|
-
if (!Object.prototype.hasOwnProperty.call(shaped, key)) {
|
|
1263
|
-
continue;
|
|
1264
|
-
}
|
|
1265
|
-
if (noBodies) {
|
|
1266
|
-
shaped[`${key}Omitted`] = true;
|
|
1267
|
-
delete shaped[key];
|
|
1268
|
-
continue;
|
|
1269
|
-
}
|
|
1270
|
-
const bodyMaxBytes = positiveNumber(options.bodyMaxBytes);
|
|
1271
|
-
if (bodyMaxBytes !== null) {
|
|
1272
|
-
shaped[key] = truncateString(shaped[key], bodyMaxBytes);
|
|
1273
|
-
}
|
|
1274
|
-
}
|
|
1275
|
-
return shaped;
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
function compactNetworkRecord(record) {
|
|
1279
|
-
const result = {
|
|
1280
|
-
id: record?.id,
|
|
1281
|
-
timestampMs: record?.timestampMs,
|
|
1282
|
-
source: record?.source,
|
|
1283
|
-
method: networkRecordMethod(record),
|
|
1284
|
-
url: networkRecordUrl(record),
|
|
1285
|
-
statusCode: networkRecordStatus(record),
|
|
1286
|
-
durationMs: record?.durationMs,
|
|
1287
|
-
error: record?.error || undefined,
|
|
1288
|
-
redacted: record?.redacted,
|
|
1289
|
-
};
|
|
1290
|
-
const contentType = networkRecordContentType(record);
|
|
1291
|
-
if (contentType) {
|
|
1292
|
-
result.contentType = contentType;
|
|
1293
|
-
}
|
|
1294
|
-
if (Object.prototype.hasOwnProperty.call(record || {}, 'requestBody')) {
|
|
1295
|
-
result.requestBodyBytes = bodyByteLength(record.requestBody);
|
|
1296
|
-
}
|
|
1297
|
-
if (Object.prototype.hasOwnProperty.call(record || {}, 'responseBody')) {
|
|
1298
|
-
result.responseBodyBytes = bodyByteLength(record.responseBody);
|
|
1299
|
-
}
|
|
1300
|
-
return pruneUndefined(result);
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
function networkResultOptions(options = {}) {
|
|
1304
|
-
const result = {};
|
|
1305
|
-
if (booleanOption(options.compact)) result.compact = true;
|
|
1306
|
-
if (options.urlFilter) result.urlFilter = String(options.urlFilter);
|
|
1307
|
-
if (options.method) result.method = String(options.method).toUpperCase();
|
|
1308
|
-
if (options.statusCode !== undefined && options.statusCode !== null && options.statusCode !== '') {
|
|
1309
|
-
result.statusCode = Number(options.statusCode);
|
|
1310
|
-
}
|
|
1311
|
-
if (booleanOption(options.noBodies)) result.noBodies = true;
|
|
1312
|
-
if (positiveNumber(options.bodyMaxBytes) !== null) result.bodyMaxBytes = positiveNumber(options.bodyMaxBytes);
|
|
1313
|
-
return result;
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
function networkRecordUrl(record) {
|
|
1317
|
-
return String(record?.url || record?.requestUrl || record?.responseUrl || record?.response?.url || '');
|
|
1318
|
-
}
|
|
1319
|
-
|
|
1320
|
-
function networkRecordMethod(record) {
|
|
1321
|
-
return String(record?.method || record?.requestMethod || record?.request?.method || '');
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
function networkRecordStatus(record) {
|
|
1325
|
-
return record?.statusCode ?? record?.responseStatusCode ?? record?.status ?? record?.response?.statusCode;
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
function networkRecordContentType(record) {
|
|
1329
|
-
return headerValue(record?.responseHeaders, 'content-type') ||
|
|
1330
|
-
headerValue(record?.requestHeaders, 'content-type') ||
|
|
1331
|
-
'';
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
function headerValue(headers, key) {
|
|
1335
|
-
if (!headers || typeof headers !== 'object') {
|
|
1336
|
-
return '';
|
|
1337
|
-
}
|
|
1338
|
-
const expected = key.toLowerCase();
|
|
1339
|
-
for (const [name, value] of Object.entries(headers)) {
|
|
1340
|
-
if (String(name).toLowerCase() === expected) {
|
|
1341
|
-
return String(value);
|
|
1342
|
-
}
|
|
1343
|
-
}
|
|
1344
|
-
return '';
|
|
1345
|
-
}
|
|
1346
|
-
|
|
1347
|
-
function bodyByteLength(value) {
|
|
1348
|
-
if (value === undefined || value === null) {
|
|
1349
|
-
return 0;
|
|
1350
|
-
}
|
|
1351
|
-
return Buffer.byteLength(String(value), 'utf8');
|
|
1352
|
-
}
|
|
1353
|
-
|
|
1354
|
-
function positiveNumber(value) {
|
|
1355
|
-
const number = Number(value);
|
|
1356
|
-
return Number.isFinite(number) && number >= 0 ? number : null;
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
function pruneUndefined(value) {
|
|
1360
|
-
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
1361
|
-
}
|
|
1362
|
-
|
|
1363
|
-
function withQuery(requestPath, query) {
|
|
1364
|
-
const params = new URLSearchParams();
|
|
1365
|
-
for (const [key, value] of Object.entries(query || {})) {
|
|
1366
|
-
if (value === undefined || value === null || value === '' || value === false) continue;
|
|
1367
|
-
params.set(key, String(value));
|
|
1368
|
-
}
|
|
1369
|
-
const queryString = params.toString();
|
|
1370
|
-
return queryString ? `${requestPath}?${queryString}` : requestPath;
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
async function webviewPages(ctx, options) {
|
|
1374
|
-
const setup = await setupWebViewDevTools(ctx, options, { selectPage: false });
|
|
1375
|
-
const keepForward = booleanOption(options.keepForward);
|
|
1376
|
-
if (!keepForward) {
|
|
1377
|
-
await setup.cleanup();
|
|
1378
|
-
}
|
|
1379
|
-
return {
|
|
1380
|
-
ok: true,
|
|
1381
|
-
transport: 'webview-devtools-cdp',
|
|
1382
|
-
packageName: ctx.packageName,
|
|
1383
|
-
sockets: setup.sockets,
|
|
1384
|
-
selectedSocket: setup.socket,
|
|
1385
|
-
packagePids: setup.packagePids,
|
|
1386
|
-
forward: {
|
|
1387
|
-
localPort: setup.localPort,
|
|
1388
|
-
socketName: setup.socket.name,
|
|
1389
|
-
active: keepForward,
|
|
1390
|
-
},
|
|
1391
|
-
pages: setup.pages,
|
|
1392
|
-
pageCount: setup.pages.length,
|
|
1393
|
-
selectionWarning: setup.selectionWarning || null,
|
|
1394
|
-
};
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
async function webviewCdpCapture(ctx, options) {
|
|
1398
|
-
const setup = await setupWebViewDevTools(ctx, options, { selectPage: true });
|
|
1399
|
-
const durationMs = Math.max(0, Number(options.durationMs || 3000));
|
|
1400
|
-
const captureNetwork = options.captureNetwork !== false;
|
|
1401
|
-
const captureConsole = options.captureConsole !== false;
|
|
1402
|
-
const includeResponseBody = booleanOption(options.includeResponseBody);
|
|
1403
|
-
const maxEvents = Number(options.maxEvents || 200);
|
|
1404
|
-
const bodyMaxBytes = Number(options.bodyMaxBytes || 64 * 1024);
|
|
1405
|
-
const urlFilter = options.urlFilter || '';
|
|
1406
|
-
const events = [];
|
|
1407
|
-
const requests = new Map();
|
|
1408
|
-
const consoleEvents = [];
|
|
1409
|
-
let scriptResult = null;
|
|
1410
|
-
let cdp = null;
|
|
1411
|
-
|
|
1412
|
-
const handleEvent = (event) => {
|
|
1413
|
-
if (events.length < maxEvents) {
|
|
1414
|
-
events.push({
|
|
1415
|
-
method: event.method,
|
|
1416
|
-
params: summarizeCdpParams(event.method, event.params),
|
|
1417
|
-
});
|
|
1418
|
-
}
|
|
1419
|
-
if (captureNetwork && event.method === 'Network.requestWillBeSent') {
|
|
1420
|
-
const request = event.params?.request || {};
|
|
1421
|
-
if (urlFilter && !String(request.url || '').includes(urlFilter)) return;
|
|
1422
|
-
const entry = requests.get(event.params.requestId) || {};
|
|
1423
|
-
requests.set(event.params.requestId, {
|
|
1424
|
-
...entry,
|
|
1425
|
-
requestId: event.params.requestId,
|
|
1426
|
-
loaderId: event.params.loaderId,
|
|
1427
|
-
documentURL: event.params.documentURL,
|
|
1428
|
-
type: event.params.type,
|
|
1429
|
-
timestamp: event.params.timestamp,
|
|
1430
|
-
wallTime: event.params.wallTime,
|
|
1431
|
-
method: request.method,
|
|
1432
|
-
url: request.url,
|
|
1433
|
-
requestHeaders: request.headers || {},
|
|
1434
|
-
requestPostData: truncateString(request.postData || '', bodyMaxBytes),
|
|
1435
|
-
});
|
|
1436
|
-
return;
|
|
1437
|
-
}
|
|
1438
|
-
if (captureNetwork && event.method === 'Network.responseReceived') {
|
|
1439
|
-
const response = event.params?.response || {};
|
|
1440
|
-
if (urlFilter && !String(response.url || '').includes(urlFilter)) return;
|
|
1441
|
-
const entry = requests.get(event.params.requestId) || {};
|
|
1442
|
-
requests.set(event.params.requestId, {
|
|
1443
|
-
...entry,
|
|
1444
|
-
requestId: event.params.requestId,
|
|
1445
|
-
type: event.params.type || entry.type,
|
|
1446
|
-
status: response.status,
|
|
1447
|
-
statusText: response.statusText,
|
|
1448
|
-
responseUrl: response.url,
|
|
1449
|
-
mimeType: response.mimeType,
|
|
1450
|
-
protocol: response.protocol,
|
|
1451
|
-
remoteIPAddress: response.remoteIPAddress,
|
|
1452
|
-
remotePort: response.remotePort,
|
|
1453
|
-
responseHeaders: response.headers || {},
|
|
1454
|
-
});
|
|
1455
|
-
return;
|
|
1456
|
-
}
|
|
1457
|
-
if (captureNetwork && event.method === 'Network.responseReceivedExtraInfo') {
|
|
1458
|
-
const entry = requests.get(event.params.requestId) || {};
|
|
1459
|
-
if (urlFilter && !String(entry.url || entry.responseUrl || '').includes(urlFilter)) return;
|
|
1460
|
-
requests.set(event.params.requestId, {
|
|
1461
|
-
...entry,
|
|
1462
|
-
requestId: event.params.requestId,
|
|
1463
|
-
status: entry.status ?? event.params.statusCode,
|
|
1464
|
-
responseHeaders: {
|
|
1465
|
-
...(entry.responseHeaders || {}),
|
|
1466
|
-
...(event.params.headers || {}),
|
|
1467
|
-
},
|
|
1468
|
-
responseHeadersText: event.params.headersText,
|
|
1469
|
-
resourceIPAddressSpace: event.params.resourceIPAddressSpace,
|
|
1470
|
-
});
|
|
1471
|
-
return;
|
|
1472
|
-
}
|
|
1473
|
-
if (captureNetwork && event.method === 'Network.loadingFinished') {
|
|
1474
|
-
const entry = requests.get(event.params.requestId) || {};
|
|
1475
|
-
requests.set(event.params.requestId, {
|
|
1476
|
-
...entry,
|
|
1477
|
-
requestId: event.params.requestId,
|
|
1478
|
-
encodedDataLength: event.params.encodedDataLength,
|
|
1479
|
-
finished: true,
|
|
1480
|
-
});
|
|
1481
|
-
return;
|
|
1482
|
-
}
|
|
1483
|
-
if (captureNetwork && event.method === 'Network.loadingFailed') {
|
|
1484
|
-
const entry = requests.get(event.params.requestId) || {};
|
|
1485
|
-
requests.set(event.params.requestId, {
|
|
1486
|
-
...entry,
|
|
1487
|
-
requestId: event.params.requestId,
|
|
1488
|
-
failed: true,
|
|
1489
|
-
errorText: event.params.errorText,
|
|
1490
|
-
canceled: event.params.canceled,
|
|
1491
|
-
blockedReason: event.params.blockedReason,
|
|
1492
|
-
corsErrorStatus: event.params.corsErrorStatus,
|
|
1493
|
-
});
|
|
1494
|
-
return;
|
|
1495
|
-
}
|
|
1496
|
-
if (captureConsole && event.method === 'Runtime.consoleAPICalled') {
|
|
1497
|
-
consoleEvents.push({
|
|
1498
|
-
type: event.params?.type,
|
|
1499
|
-
timestamp: event.params?.timestamp,
|
|
1500
|
-
args: (event.params?.args || []).map(cdpRemoteValue),
|
|
1501
|
-
stackTrace: event.params?.stackTrace || null,
|
|
1502
|
-
});
|
|
1503
|
-
return;
|
|
1504
|
-
}
|
|
1505
|
-
if (captureConsole && event.method === 'Log.entryAdded') {
|
|
1506
|
-
const entry = event.params?.entry || {};
|
|
1507
|
-
consoleEvents.push({
|
|
1508
|
-
source: entry.source,
|
|
1509
|
-
level: entry.level,
|
|
1510
|
-
text: entry.text,
|
|
1511
|
-
url: entry.url,
|
|
1512
|
-
lineNumber: entry.lineNumber,
|
|
1513
|
-
timestamp: entry.timestamp,
|
|
1514
|
-
});
|
|
1515
|
-
}
|
|
1516
|
-
};
|
|
1517
|
-
|
|
1518
|
-
try {
|
|
1519
|
-
cdp = await CdpSession.open(setup.page.webSocketDebuggerUrl);
|
|
1520
|
-
cdp.onEvent(handleEvent);
|
|
1521
|
-
if (captureNetwork) {
|
|
1522
|
-
await cdp.send('Network.enable');
|
|
1523
|
-
}
|
|
1524
|
-
if (captureConsole) {
|
|
1525
|
-
await cdp.send('Runtime.enable');
|
|
1526
|
-
await cdp.send('Log.enable').catch(() => null);
|
|
1527
|
-
}
|
|
1528
|
-
if (options.script) {
|
|
1529
|
-
scriptResult = await cdp.send('Runtime.evaluate', {
|
|
1530
|
-
expression: String(options.script),
|
|
1531
|
-
awaitPromise: true,
|
|
1532
|
-
returnByValue: true,
|
|
1533
|
-
});
|
|
1534
|
-
}
|
|
1535
|
-
await sleep(durationMs);
|
|
1536
|
-
if (includeResponseBody && captureNetwork) {
|
|
1537
|
-
for (const entry of requests.values()) {
|
|
1538
|
-
if (entry.status === undefined || entry.failed) continue;
|
|
1539
|
-
try {
|
|
1540
|
-
const body = await cdp.send('Network.getResponseBody', { requestId: entry.requestId }, 2500);
|
|
1541
|
-
entry.responseBody = truncateString(body.body || '', bodyMaxBytes);
|
|
1542
|
-
entry.base64Encoded = Boolean(body.base64Encoded);
|
|
1543
|
-
} catch (error) {
|
|
1544
|
-
entry.responseBodyError = firstErrorLine(error);
|
|
1545
|
-
}
|
|
1546
|
-
}
|
|
1547
|
-
}
|
|
1548
|
-
} finally {
|
|
1549
|
-
if (cdp) cdp.close();
|
|
1550
|
-
await setup.cleanup();
|
|
1551
|
-
}
|
|
1552
|
-
|
|
1553
|
-
const requestItems = Array.from(requests.values());
|
|
1554
|
-
return {
|
|
1555
|
-
ok: true,
|
|
1556
|
-
transport: 'webview-devtools-cdp',
|
|
1557
|
-
packageName: ctx.packageName,
|
|
1558
|
-
socket: setup.socket,
|
|
1559
|
-
packagePids: setup.packagePids,
|
|
1560
|
-
page: setup.page,
|
|
1561
|
-
durationMs,
|
|
1562
|
-
captureNetwork,
|
|
1563
|
-
captureConsole,
|
|
1564
|
-
scriptResult: normalizeRuntimeEvaluateResult(scriptResult),
|
|
1565
|
-
counts: {
|
|
1566
|
-
events: events.length,
|
|
1567
|
-
requests: requestItems.length,
|
|
1568
|
-
console: consoleEvents.length,
|
|
1569
|
-
},
|
|
1570
|
-
requests: requestItems,
|
|
1571
|
-
console: consoleEvents,
|
|
1572
|
-
events,
|
|
1573
|
-
selectionWarning: setup.selectionWarning || null,
|
|
1574
|
-
};
|
|
1575
|
-
}
|
|
1576
|
-
|
|
1577
|
-
async function setupWebViewDevTools(ctx, options, behavior = {}) {
|
|
1578
|
-
const packagePids = await packagePidsFor(ctx);
|
|
1579
|
-
const procNetUnix = (await adb(ctx, ['shell', 'cat', '/proc/net/unix'])).stdout;
|
|
1580
|
-
const sockets = parseWebViewDevToolsSockets(procNetUnix, packagePids);
|
|
1581
|
-
if (sockets.length === 0) {
|
|
1582
|
-
throw new Error('no WebView DevTools socket found; make sure the app is running and WebView debugging is enabled');
|
|
1583
|
-
}
|
|
1584
|
-
const choice = chooseWebViewDevToolsSocket(sockets, options, packagePids);
|
|
1585
|
-
if (!choice.socket) {
|
|
1586
|
-
throw new Error(choice.error || 'no matching WebView DevTools socket found');
|
|
1587
|
-
}
|
|
1588
|
-
const localPort = await resolveWebViewDevToolsPort(ctx, options);
|
|
1589
|
-
await removeAdbForwardIfPresent(ctx, localPort);
|
|
1590
|
-
await adb(ctx, ['forward', `tcp:${localPort}`, `localabstract:${choice.socket.name}`]);
|
|
1591
|
-
let pages = [];
|
|
1592
|
-
try {
|
|
1593
|
-
pages = JSON.parse(await httpGet(`http://127.0.0.1:${localPort}/json`));
|
|
1594
|
-
if (!Array.isArray(pages)) pages = [];
|
|
1595
|
-
pages = pages.map((page) => normalizeCdpPage(page, localPort));
|
|
1596
|
-
} catch (error) {
|
|
1597
|
-
await removeAdbForwardIfPresent(ctx, localPort);
|
|
1598
|
-
throw error;
|
|
1599
|
-
}
|
|
1600
|
-
const page = behavior.selectPage === false ? null : chooseWebViewPage(pages, options);
|
|
1601
|
-
if (behavior.selectPage !== false && !page) {
|
|
1602
|
-
await removeAdbForwardIfPresent(ctx, localPort);
|
|
1603
|
-
throw new Error('no attachable WebView CDP page found');
|
|
1604
|
-
}
|
|
1605
|
-
return {
|
|
1606
|
-
sockets,
|
|
1607
|
-
socket: choice.socket,
|
|
1608
|
-
packagePids,
|
|
1609
|
-
selectionWarning: choice.warning,
|
|
1610
|
-
localPort,
|
|
1611
|
-
pages,
|
|
1612
|
-
page,
|
|
1613
|
-
cleanup: () => removeAdbForwardIfPresent(ctx, localPort),
|
|
1614
|
-
};
|
|
1615
|
-
}
|
|
1616
|
-
|
|
1617
|
-
async function packagePidsFor(ctx) {
|
|
1618
|
-
if (!ctx.packageName) return [];
|
|
1619
|
-
try {
|
|
1620
|
-
const result = await adb(ctx, ['shell', 'pidof', ctx.packageName]);
|
|
1621
|
-
return result.stdout.split(/\s+/).map((value) => value.trim()).filter((value) => /^\d+$/.test(value));
|
|
1622
|
-
} catch (_) {
|
|
1623
|
-
return [];
|
|
91
|
+
if (Object.hasOwn(options, name)) {
|
|
92
|
+
throw new CommandError('duplicate_argument', `CLI flag ${name} may be supplied only once. Only --category and --extra accept repeated values.`, { field: name });
|
|
1624
93
|
}
|
|
94
|
+
options[name] = value;
|
|
1625
95
|
}
|
|
1626
96
|
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
}
|
|
1630
|
-
|
|
1631
|
-
async function thawApp(ctx, options = {}) {
|
|
1632
|
-
return signalAppProcesses(ctx, options, 'SIGCONT', 'thaw-app');
|
|
1633
|
-
}
|
|
1634
|
-
|
|
1635
|
-
async function signalAppProcesses(ctx, options, signal, action) {
|
|
1636
|
-
const pids = await targetAppPids(ctx, options);
|
|
1637
|
-
if (pids.length === 0) {
|
|
1638
|
-
return {
|
|
1639
|
-
ok: false,
|
|
1640
|
-
packageName: ctx.packageName,
|
|
1641
|
-
action,
|
|
1642
|
-
signal,
|
|
1643
|
-
error: 'app_process_not_found',
|
|
1644
|
-
};
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
const results = [];
|
|
1648
|
-
for (const pid of pids) {
|
|
1649
|
-
try {
|
|
1650
|
-
const result = await adb(ctx, ['shell', 'run-as', ctx.packageName, 'kill', `-${signal}`, pid]);
|
|
1651
|
-
results.push({
|
|
1652
|
-
pid,
|
|
1653
|
-
ok: true,
|
|
1654
|
-
stdout: result.stdout.trim(),
|
|
1655
|
-
stderr: result.stderr.trim(),
|
|
1656
|
-
});
|
|
1657
|
-
} catch (error) {
|
|
1658
|
-
results.push({
|
|
1659
|
-
pid,
|
|
1660
|
-
ok: false,
|
|
1661
|
-
error: firstErrorLine(error),
|
|
1662
|
-
});
|
|
1663
|
-
}
|
|
1664
|
-
}
|
|
1665
|
-
|
|
1666
|
-
const failed = results.filter((item) => !item.ok);
|
|
1667
|
-
return {
|
|
1668
|
-
ok: failed.length === 0,
|
|
1669
|
-
packageName: ctx.packageName,
|
|
1670
|
-
action,
|
|
1671
|
-
signal,
|
|
1672
|
-
pids,
|
|
1673
|
-
results,
|
|
1674
|
-
...(failed.length > 0 ? { error: 'signal_failed' } : {}),
|
|
1675
|
-
};
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
|
-
async function targetAppPids(ctx, options = {}) {
|
|
1679
|
-
if (options.pid && options.pid !== true && options.pid !== 'current') {
|
|
1680
|
-
const explicitPid = String(options.pid).trim();
|
|
1681
|
-
return /^\d+$/.test(explicitPid) ? [explicitPid] : [];
|
|
1682
|
-
}
|
|
1683
|
-
const psPids = await packagePidsFromPs(ctx);
|
|
1684
|
-
if (psPids.length > 0) return psPids;
|
|
1685
|
-
return packagePidsFor(ctx);
|
|
1686
|
-
}
|
|
1687
|
-
|
|
1688
|
-
async function packagePidsFromPs(ctx) {
|
|
1689
|
-
if (!ctx.packageName) return [];
|
|
1690
|
-
try {
|
|
1691
|
-
const result = await adb(ctx, ['shell', 'ps', '-A', '-o', 'PID,NAME']);
|
|
1692
|
-
return parsePackagePidsFromPs(result.stdout, ctx.packageName);
|
|
1693
|
-
} catch (_) {
|
|
1694
|
-
return [];
|
|
1695
|
-
}
|
|
1696
|
-
}
|
|
1697
|
-
|
|
1698
|
-
function parsePackagePidsFromPs(stdout, packageName) {
|
|
1699
|
-
const pids = [];
|
|
1700
|
-
const seen = new Set();
|
|
1701
|
-
for (const line of String(stdout || '').split(/\r?\n/)) {
|
|
1702
|
-
const trimmed = line.trim();
|
|
1703
|
-
if (!trimmed || /^PID\s+NAME$/i.test(trimmed)) continue;
|
|
1704
|
-
const match = /^(\d+)\s+(\S+)$/.exec(trimmed);
|
|
1705
|
-
if (!match) continue;
|
|
1706
|
-
const pid = match[1];
|
|
1707
|
-
const processName = match[2];
|
|
1708
|
-
if (processName !== packageName && !processName.startsWith(`${packageName}:`)) continue;
|
|
1709
|
-
if (seen.has(pid)) continue;
|
|
1710
|
-
seen.add(pid);
|
|
1711
|
-
pids.push(pid);
|
|
1712
|
-
}
|
|
1713
|
-
return pids;
|
|
1714
|
-
}
|
|
1715
|
-
|
|
1716
|
-
function parseWebViewDevToolsSockets(procNetUnix, packagePids = []) {
|
|
1717
|
-
const packagePidSet = new Set(packagePids.map(String));
|
|
1718
|
-
const sockets = [];
|
|
1719
|
-
const seen = new Set();
|
|
1720
|
-
const lines = String(procNetUnix || '').split(/\r?\n/);
|
|
1721
|
-
for (const line of lines) {
|
|
1722
|
-
const matches = line.matchAll(/(?:^|\s|@)(webview_devtools_remote(?:_[^\s@]+)?)/g);
|
|
1723
|
-
for (const match of matches) {
|
|
1724
|
-
const name = match[1];
|
|
1725
|
-
if (!name || seen.has(name)) continue;
|
|
1726
|
-
seen.add(name);
|
|
1727
|
-
const pidMatch = /_(\d+)$/.exec(name);
|
|
1728
|
-
const pid = pidMatch ? pidMatch[1] : null;
|
|
1729
|
-
sockets.push({
|
|
1730
|
-
name,
|
|
1731
|
-
rawName: match[0].trim(),
|
|
1732
|
-
pid,
|
|
1733
|
-
packageMatch: Boolean(pid && packagePidSet.has(pid)),
|
|
1734
|
-
line: line.trim(),
|
|
1735
|
-
});
|
|
1736
|
-
}
|
|
1737
|
-
}
|
|
1738
|
-
return sockets;
|
|
1739
|
-
}
|
|
1740
|
-
|
|
1741
|
-
function chooseWebViewDevToolsSocket(sockets, options = {}, packagePids = []) {
|
|
1742
|
-
if (options.socketName) {
|
|
1743
|
-
const requested = String(options.socketName).replace(/^@/, '');
|
|
1744
|
-
const socket = sockets.find((item) => item.name === requested);
|
|
1745
|
-
return socket ? { socket } : { error: `requested WebView DevTools socket not found: ${requested}` };
|
|
1746
|
-
}
|
|
1747
|
-
for (const pid of packagePids.map(String)) {
|
|
1748
|
-
const socket = sockets.find((item) => item.pid === pid);
|
|
1749
|
-
if (socket) return { socket };
|
|
1750
|
-
}
|
|
1751
|
-
const matched = sockets.filter((item) => item.packageMatch);
|
|
1752
|
-
if (matched.length > 0) return { socket: matched[0] };
|
|
1753
|
-
if (sockets.length === 1) return { socket: sockets[0] };
|
|
1754
|
-
return {
|
|
1755
|
-
socket: sockets[0],
|
|
1756
|
-
warning: `multiple WebView DevTools sockets found and none matched ${packagePids.join(',') || 'the target package pid'}; selected ${sockets[0].name}`,
|
|
1757
|
-
};
|
|
1758
|
-
}
|
|
1759
|
-
|
|
1760
|
-
function chooseWebViewPage(pages, options = {}) {
|
|
1761
|
-
if (!Array.isArray(pages) || pages.length === 0) return null;
|
|
1762
|
-
if (options.targetId) {
|
|
1763
|
-
const targetId = String(options.targetId);
|
|
1764
|
-
const byId = pages.find((page) => page.id === targetId);
|
|
1765
|
-
if (byId) return byId;
|
|
1766
|
-
}
|
|
1767
|
-
if (options.pageUrlFilter) {
|
|
1768
|
-
const filter = String(options.pageUrlFilter);
|
|
1769
|
-
const byUrl = pages.find((page) => String(page.url || '').includes(filter));
|
|
1770
|
-
if (byUrl) return byUrl;
|
|
1771
|
-
}
|
|
1772
|
-
return pages.find((page) => page.webSocketDebuggerUrl && page.type === 'page') ||
|
|
1773
|
-
pages.find((page) => page.webSocketDebuggerUrl) ||
|
|
1774
|
-
null;
|
|
1775
|
-
}
|
|
1776
|
-
|
|
1777
|
-
function normalizeCdpPage(page, localPort) {
|
|
1778
|
-
const normalized = {
|
|
1779
|
-
id: page.id,
|
|
1780
|
-
type: page.type,
|
|
1781
|
-
title: page.title,
|
|
1782
|
-
url: page.url,
|
|
1783
|
-
description: page.description,
|
|
1784
|
-
webSocketDebuggerUrl: page.webSocketDebuggerUrl,
|
|
1785
|
-
};
|
|
1786
|
-
if (normalized.webSocketDebuggerUrl) {
|
|
1787
|
-
normalized.webSocketDebuggerUrl = normalized.webSocketDebuggerUrl.replace(
|
|
1788
|
-
/^ws:\/\/(?:\[::\]|localhost|127\.0\.0\.1):\d+/,
|
|
1789
|
-
`ws://127.0.0.1:${localPort}`,
|
|
1790
|
-
);
|
|
1791
|
-
}
|
|
1792
|
-
return normalized;
|
|
1793
|
-
}
|
|
1794
|
-
|
|
1795
|
-
async function resolveWebViewDevToolsPort(ctx, options) {
|
|
1796
|
-
const explicit = Number(options.webviewPort || options.devtoolsPort || options.cdpPort || 0);
|
|
1797
|
-
if (Number.isInteger(explicit) && explicit > 0) return explicit;
|
|
1798
|
-
const start = 9222;
|
|
1799
|
-
for (let port = start; port < start + 100; port += 1) {
|
|
1800
|
-
await removeAdbForwardIfPresent(ctx, port);
|
|
1801
|
-
if (await isLocalPortAvailable(port)) return port;
|
|
1802
|
-
}
|
|
1803
|
-
throw new Error('no available local port for WebView DevTools forwarding');
|
|
1804
|
-
}
|
|
1805
|
-
|
|
1806
|
-
function isLocalPortAvailable(port) {
|
|
1807
|
-
return new Promise((resolve) => {
|
|
1808
|
-
const server = net.createServer();
|
|
1809
|
-
server.once('error', () => resolve(false));
|
|
1810
|
-
server.once('listening', () => {
|
|
1811
|
-
server.close(() => resolve(true));
|
|
1812
|
-
});
|
|
1813
|
-
server.listen(port, '127.0.0.1');
|
|
1814
|
-
});
|
|
1815
|
-
}
|
|
1816
|
-
|
|
1817
|
-
async function removeAdbForwardIfPresent(ctx, port) {
|
|
1818
|
-
try {
|
|
1819
|
-
await adb(ctx, ['forward', '--remove', `tcp:${port}`]);
|
|
1820
|
-
} catch (_) {
|
|
1821
|
-
// A missing forward is the common case.
|
|
1822
|
-
}
|
|
1823
|
-
}
|
|
1824
|
-
|
|
1825
|
-
class CdpSession {
|
|
1826
|
-
constructor(socket) {
|
|
1827
|
-
this.socket = socket;
|
|
1828
|
-
this.nextId = 1;
|
|
1829
|
-
this.pending = new Map();
|
|
1830
|
-
this.eventHandlers = [];
|
|
1831
|
-
}
|
|
1832
|
-
|
|
1833
|
-
static async open(url) {
|
|
1834
|
-
const WebSocketCtor = webSocketConstructor();
|
|
1835
|
-
const socket = new WebSocketCtor(url);
|
|
1836
|
-
await waitForWebSocketOpen(socket, url);
|
|
1837
|
-
const session = new CdpSession(socket);
|
|
1838
|
-
addWebSocketMessageHandler(socket, (data) => {
|
|
1839
|
-
webSocketDataToText(data)
|
|
1840
|
-
.then((text) => session.handleMessage(text))
|
|
1841
|
-
.catch(() => null);
|
|
1842
|
-
});
|
|
1843
|
-
addWebSocketCloseHandler(socket, () => session.rejectAll(new Error('CDP WebSocket closed')));
|
|
1844
|
-
return session;
|
|
1845
|
-
}
|
|
1846
|
-
|
|
1847
|
-
onEvent(handler) {
|
|
1848
|
-
this.eventHandlers.push(handler);
|
|
1849
|
-
}
|
|
1850
|
-
|
|
1851
|
-
send(method, params = {}, timeoutMs = 5000) {
|
|
1852
|
-
const id = this.nextId;
|
|
1853
|
-
this.nextId += 1;
|
|
1854
|
-
const payload = JSON.stringify({ id, method, params });
|
|
1855
|
-
return new Promise((resolve, reject) => {
|
|
1856
|
-
const timer = setTimeout(() => {
|
|
1857
|
-
this.pending.delete(id);
|
|
1858
|
-
reject(new Error(`CDP timeout: ${method}`));
|
|
1859
|
-
}, timeoutMs);
|
|
1860
|
-
this.pending.set(id, { resolve, reject, timer, method });
|
|
1861
|
-
this.socket.send(payload);
|
|
1862
|
-
});
|
|
1863
|
-
}
|
|
1864
|
-
|
|
1865
|
-
handleMessage(text) {
|
|
1866
|
-
const message = JSON.parse(text);
|
|
1867
|
-
if (message.id !== undefined) {
|
|
1868
|
-
const pending = this.pending.get(message.id);
|
|
1869
|
-
if (!pending) return;
|
|
1870
|
-
clearTimeout(pending.timer);
|
|
1871
|
-
this.pending.delete(message.id);
|
|
1872
|
-
if (message.error) {
|
|
1873
|
-
pending.reject(new Error(`CDP ${pending.method} failed: ${message.error.message || JSON.stringify(message.error)}`));
|
|
1874
|
-
} else {
|
|
1875
|
-
pending.resolve(message.result || {});
|
|
1876
|
-
}
|
|
1877
|
-
return;
|
|
1878
|
-
}
|
|
1879
|
-
if (message.method) {
|
|
1880
|
-
for (const handler of this.eventHandlers) {
|
|
1881
|
-
handler(message);
|
|
1882
|
-
}
|
|
1883
|
-
}
|
|
1884
|
-
}
|
|
1885
|
-
|
|
1886
|
-
rejectAll(error) {
|
|
1887
|
-
for (const [id, pending] of this.pending.entries()) {
|
|
1888
|
-
clearTimeout(pending.timer);
|
|
1889
|
-
pending.reject(error);
|
|
1890
|
-
this.pending.delete(id);
|
|
1891
|
-
}
|
|
1892
|
-
}
|
|
1893
|
-
|
|
1894
|
-
close() {
|
|
1895
|
-
try {
|
|
1896
|
-
this.socket.close();
|
|
1897
|
-
} catch (_) {
|
|
1898
|
-
// Ignore close races.
|
|
1899
|
-
}
|
|
1900
|
-
}
|
|
1901
|
-
}
|
|
1902
|
-
|
|
1903
|
-
function webSocketConstructor() {
|
|
1904
|
-
if (typeof globalThis.WebSocket === 'function') return globalThis.WebSocket;
|
|
1905
|
-
try {
|
|
1906
|
-
return require('ws');
|
|
1907
|
-
} catch (_) {
|
|
1908
|
-
throw new Error('WebView CDP capture requires Node.js with WebSocket support or the ws package');
|
|
1909
|
-
}
|
|
1910
|
-
}
|
|
1911
|
-
|
|
1912
|
-
function waitForWebSocketOpen(socket, url) {
|
|
1913
|
-
return new Promise((resolve, reject) => {
|
|
1914
|
-
const timer = setTimeout(() => reject(new Error(`CDP WebSocket open timeout: ${url}`)), 10000);
|
|
1915
|
-
const done = (error) => {
|
|
1916
|
-
clearTimeout(timer);
|
|
1917
|
-
if (error) reject(error);
|
|
1918
|
-
else resolve();
|
|
1919
|
-
};
|
|
1920
|
-
if (typeof socket.addEventListener === 'function') {
|
|
1921
|
-
socket.addEventListener('open', () => done(), { once: true });
|
|
1922
|
-
socket.addEventListener('error', () => done(new Error(`CDP WebSocket error: ${url}`)), { once: true });
|
|
1923
|
-
return;
|
|
1924
|
-
}
|
|
1925
|
-
socket.once('open', () => done());
|
|
1926
|
-
socket.once('error', (error) => done(error));
|
|
1927
|
-
});
|
|
1928
|
-
}
|
|
1929
|
-
|
|
1930
|
-
function addWebSocketMessageHandler(socket, handler) {
|
|
1931
|
-
if (typeof socket.addEventListener === 'function') {
|
|
1932
|
-
socket.addEventListener('message', (event) => handler(event.data));
|
|
1933
|
-
return;
|
|
1934
|
-
}
|
|
1935
|
-
socket.on('message', handler);
|
|
1936
|
-
}
|
|
1937
|
-
|
|
1938
|
-
function addWebSocketCloseHandler(socket, handler) {
|
|
1939
|
-
if (typeof socket.addEventListener === 'function') {
|
|
1940
|
-
socket.addEventListener('close', handler);
|
|
1941
|
-
return;
|
|
1942
|
-
}
|
|
1943
|
-
socket.on('close', handler);
|
|
1944
|
-
}
|
|
1945
|
-
|
|
1946
|
-
async function webSocketDataToText(data) {
|
|
1947
|
-
if (typeof data === 'string') return data;
|
|
1948
|
-
if (Buffer.isBuffer(data)) return data.toString('utf8');
|
|
1949
|
-
if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8');
|
|
1950
|
-
if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8');
|
|
1951
|
-
if (data && typeof data.text === 'function') return data.text();
|
|
1952
|
-
return String(data);
|
|
1953
|
-
}
|
|
1954
|
-
|
|
1955
|
-
function cdpRemoteValue(value) {
|
|
1956
|
-
if (!value || typeof value !== 'object') return value;
|
|
1957
|
-
if (Object.prototype.hasOwnProperty.call(value, 'value')) return value.value;
|
|
1958
|
-
if (Object.prototype.hasOwnProperty.call(value, 'unserializableValue')) return value.unserializableValue;
|
|
1959
|
-
return value.description || value.type || null;
|
|
1960
|
-
}
|
|
1961
|
-
|
|
1962
|
-
function normalizeRuntimeEvaluateResult(result) {
|
|
1963
|
-
if (!result) return null;
|
|
1964
|
-
return {
|
|
1965
|
-
result: cdpRemoteValue(result.result),
|
|
1966
|
-
exceptionDetails: result.exceptionDetails || null,
|
|
1967
|
-
};
|
|
1968
|
-
}
|
|
1969
|
-
|
|
1970
|
-
function summarizeCdpParams(method, params) {
|
|
1971
|
-
if (!params || typeof params !== 'object') return params;
|
|
1972
|
-
if (method === 'Network.requestWillBeSent') {
|
|
1973
|
-
return {
|
|
1974
|
-
requestId: params.requestId,
|
|
1975
|
-
type: params.type,
|
|
1976
|
-
documentURL: params.documentURL,
|
|
1977
|
-
request: {
|
|
1978
|
-
method: params.request?.method,
|
|
1979
|
-
url: params.request?.url,
|
|
1980
|
-
},
|
|
1981
|
-
};
|
|
1982
|
-
}
|
|
1983
|
-
if (method === 'Network.responseReceived') {
|
|
1984
|
-
return {
|
|
1985
|
-
requestId: params.requestId,
|
|
1986
|
-
type: params.type,
|
|
1987
|
-
response: {
|
|
1988
|
-
url: params.response?.url,
|
|
1989
|
-
status: params.response?.status,
|
|
1990
|
-
mimeType: params.response?.mimeType,
|
|
1991
|
-
},
|
|
1992
|
-
};
|
|
1993
|
-
}
|
|
1994
|
-
if (method === 'Network.responseReceivedExtraInfo') {
|
|
1995
|
-
return {
|
|
1996
|
-
requestId: params.requestId,
|
|
1997
|
-
statusCode: params.statusCode,
|
|
1998
|
-
resourceIPAddressSpace: params.resourceIPAddressSpace,
|
|
1999
|
-
};
|
|
2000
|
-
}
|
|
2001
|
-
if (method === 'Network.loadingFinished' || method === 'Network.loadingFailed') {
|
|
2002
|
-
return {
|
|
2003
|
-
requestId: params.requestId,
|
|
2004
|
-
encodedDataLength: params.encodedDataLength,
|
|
2005
|
-
errorText: params.errorText,
|
|
2006
|
-
blockedReason: params.blockedReason,
|
|
2007
|
-
};
|
|
2008
|
-
}
|
|
2009
|
-
if (method === 'Runtime.consoleAPICalled') {
|
|
2010
|
-
return {
|
|
2011
|
-
type: params.type,
|
|
2012
|
-
args: (params.args || []).map(cdpRemoteValue),
|
|
2013
|
-
};
|
|
2014
|
-
}
|
|
2015
|
-
if (method === 'Log.entryAdded') {
|
|
2016
|
-
return params.entry || {};
|
|
2017
|
-
}
|
|
2018
|
-
return params;
|
|
2019
|
-
}
|
|
2020
|
-
|
|
2021
|
-
function truncateString(value, maxBytes) {
|
|
2022
|
-
const text = String(value || '');
|
|
2023
|
-
if (Buffer.byteLength(text, 'utf8') <= maxBytes) return text;
|
|
2024
|
-
return `${Buffer.from(text, 'utf8').subarray(0, maxBytes).toString('utf8')}...[truncated]`;
|
|
2025
|
-
}
|
|
2026
|
-
|
|
2027
|
-
async function uiaTree(ctx) {
|
|
2028
|
-
const remotePath = '/sdcard/ai_app_window.xml';
|
|
2029
|
-
return withFileLock(uiautomatorLockPath(ctx), async () => retry(async () => {
|
|
2030
|
-
await adb(ctx, ['shell', 'uiautomator', 'dump', remotePath]);
|
|
2031
|
-
return (await adb(ctx, ['exec-out', 'cat', remotePath])).stdout;
|
|
2032
|
-
}, 4, 500), {
|
|
2033
|
-
timeoutMs: Number(process.env.AI_APP_BRIDGE_UIA_LOCK_TIMEOUT_MS || 30000),
|
|
2034
|
-
staleMs: Number(process.env.AI_APP_BRIDGE_UIA_LOCK_STALE_MS || 120000),
|
|
2035
|
-
});
|
|
2036
|
-
}
|
|
2037
|
-
|
|
2038
|
-
async function bridgeTree(ctx, options = {}) {
|
|
2039
|
-
const tree = await bridgeGet(ctx, '/v1/view/tree');
|
|
2040
|
-
return wantsCompactTree(options) ? compactBridgeTree(tree, options) : tree;
|
|
2041
|
-
}
|
|
2042
|
-
|
|
2043
|
-
async function uiaTreeCommand(ctx, options = {}) {
|
|
2044
|
-
const xml = await uiaTree(ctx);
|
|
2045
|
-
return wantsCompactTree(options) ? compactUiaTree(xml, options) : xml;
|
|
2046
|
-
}
|
|
2047
|
-
|
|
2048
|
-
function wantsCompactTree(options = {}) {
|
|
2049
|
-
return Boolean(
|
|
2050
|
-
booleanOption(options.compact) ||
|
|
2051
|
-
options.textFilter ||
|
|
2052
|
-
options.resourceIdFilter ||
|
|
2053
|
-
options.classFilter ||
|
|
2054
|
-
options.visibleOnly ||
|
|
2055
|
-
options.maxNodes ||
|
|
2056
|
-
options.maxDepth,
|
|
2057
|
-
);
|
|
2058
|
-
}
|
|
2059
|
-
|
|
2060
|
-
function compactTreeOptions(options = {}) {
|
|
2061
|
-
return {
|
|
2062
|
-
textFilter: normalizeFilter(options.textFilter || options.targetText),
|
|
2063
|
-
resourceIdFilter: normalizeFilter(options.resourceIdFilter || options.resourceFilter),
|
|
2064
|
-
classFilter: normalizeFilter(options.classFilter),
|
|
2065
|
-
visibleOnly: booleanOption(options.visibleOnly),
|
|
2066
|
-
maxNodes: boundedInteger(options.maxNodes, 80, 1, 1000),
|
|
2067
|
-
maxDepth: boundedInteger(options.maxDepth, Number.POSITIVE_INFINITY, 0, 200),
|
|
2068
|
-
};
|
|
2069
|
-
}
|
|
2070
|
-
|
|
2071
|
-
function compactBridgeTree(tree, options = {}) {
|
|
2072
|
-
const compactOptions = compactTreeOptions(options);
|
|
2073
|
-
const result = {
|
|
2074
|
-
ok: true,
|
|
2075
|
-
source: 'bridge-tree',
|
|
2076
|
-
compact: true,
|
|
2077
|
-
activity: tree?.activity || null,
|
|
2078
|
-
windowCount: Array.isArray(tree?.windows) ? tree.windows.length : undefined,
|
|
2079
|
-
originalNodeCount: Number.isFinite(Number(tree?.nodeCount)) ? Number(tree.nodeCount) : undefined,
|
|
2080
|
-
options: compactTreeResultOptions(compactOptions),
|
|
2081
|
-
nodes: [],
|
|
2082
|
-
scannedNodes: 0,
|
|
2083
|
-
matchedNodes: 0,
|
|
2084
|
-
truncated: false,
|
|
2085
|
-
updatedAtMs: tree?.updatedAtMs || Date.now(),
|
|
2086
|
-
};
|
|
2087
|
-
|
|
2088
|
-
const roots = [];
|
|
2089
|
-
if (Array.isArray(tree?.windows)) {
|
|
2090
|
-
tree.windows.forEach((windowInfo, index) => {
|
|
2091
|
-
if (windowInfo?.root) {
|
|
2092
|
-
roots.push({
|
|
2093
|
-
root: windowInfo.root,
|
|
2094
|
-
windowType: windowInfo.type || 'window',
|
|
2095
|
-
windowIndex: index,
|
|
2096
|
-
viewport: windowInfo.bounds || windowInfo.root.bounds || null,
|
|
2097
|
-
});
|
|
2098
|
-
}
|
|
2099
|
-
});
|
|
2100
|
-
}
|
|
2101
|
-
if (tree?.root) {
|
|
2102
|
-
roots.push({
|
|
2103
|
-
root: tree.root,
|
|
2104
|
-
windowType: 'root',
|
|
2105
|
-
windowIndex: null,
|
|
2106
|
-
viewport: tree.root.bounds || null,
|
|
2107
|
-
});
|
|
2108
|
-
}
|
|
2109
|
-
|
|
2110
|
-
for (const rootInfo of roots) {
|
|
2111
|
-
visitBridgeTreeNode(rootInfo.root, {
|
|
2112
|
-
result,
|
|
2113
|
-
options: compactOptions,
|
|
2114
|
-
depth: 0,
|
|
2115
|
-
parentPath: '',
|
|
2116
|
-
rootInfo,
|
|
2117
|
-
});
|
|
2118
|
-
if (result.truncated) break;
|
|
2119
|
-
}
|
|
2120
|
-
|
|
2121
|
-
return result;
|
|
2122
|
-
}
|
|
2123
|
-
|
|
2124
|
-
function visitBridgeTreeNode(node, state) {
|
|
2125
|
-
if (!node || state.result.truncated) return;
|
|
2126
|
-
state.result.scannedNodes += 1;
|
|
2127
|
-
|
|
2128
|
-
const children = Array.isArray(node.children) ? node.children : [];
|
|
2129
|
-
const path = state.parentPath ? `${state.parentPath}.${state.result.scannedNodes}` : String(state.result.scannedNodes);
|
|
2130
|
-
if (state.depth <= state.options.maxDepth && bridgeNodeMatchesCompactFilters(node, state.options, state.rootInfo.viewport)) {
|
|
2131
|
-
state.result.matchedNodes += 1;
|
|
2132
|
-
if (state.result.nodes.length >= state.options.maxNodes) {
|
|
2133
|
-
state.result.truncated = true;
|
|
2134
|
-
return;
|
|
2135
|
-
}
|
|
2136
|
-
state.result.nodes.push(compactBridgeNode(node, {
|
|
2137
|
-
depth: state.depth,
|
|
2138
|
-
path,
|
|
2139
|
-
childCount: children.length,
|
|
2140
|
-
windowType: state.rootInfo.windowType,
|
|
2141
|
-
windowIndex: state.rootInfo.windowIndex,
|
|
2142
|
-
}));
|
|
2143
|
-
}
|
|
2144
|
-
|
|
2145
|
-
if (state.depth >= state.options.maxDepth) return;
|
|
2146
|
-
for (const child of children) {
|
|
2147
|
-
visitBridgeTreeNode(child, {
|
|
2148
|
-
...state,
|
|
2149
|
-
depth: state.depth + 1,
|
|
2150
|
-
parentPath: path,
|
|
2151
|
-
});
|
|
2152
|
-
if (state.result.truncated) return;
|
|
2153
|
-
}
|
|
2154
|
-
}
|
|
2155
|
-
|
|
2156
|
-
function bridgeNodeMatchesCompactFilters(node, options, viewport) {
|
|
2157
|
-
if (options.visibleOnly && nodeTapState(node, viewport).ok !== true) return false;
|
|
2158
|
-
const text = compactString(node.text || node.contentDescription || '');
|
|
2159
|
-
const resource = compactString(node.resourceName || node.id || '');
|
|
2160
|
-
const className = compactString(node.className || node.simpleClassName || '');
|
|
2161
|
-
return compactFiltersMatch({ text, resource, className }, options);
|
|
2162
|
-
}
|
|
2163
|
-
|
|
2164
|
-
function compactBridgeNode(node, extra) {
|
|
2165
|
-
return {
|
|
2166
|
-
depth: extra.depth,
|
|
2167
|
-
path: extra.path,
|
|
2168
|
-
windowType: extra.windowType,
|
|
2169
|
-
windowIndex: extra.windowIndex,
|
|
2170
|
-
className: node.simpleClassName || node.className || '',
|
|
2171
|
-
resourceName: node.resourceName || null,
|
|
2172
|
-
text: node.text || null,
|
|
2173
|
-
contentDescription: node.contentDescription || null,
|
|
2174
|
-
visible: node.visible !== false && node.effectiveVisible !== false,
|
|
2175
|
-
enabled: node.enabled !== false,
|
|
2176
|
-
clickable: Boolean(node.clickable),
|
|
2177
|
-
focusable: Boolean(node.focusable),
|
|
2178
|
-
focused: Boolean(node.focused),
|
|
2179
|
-
selected: Boolean(node.selected),
|
|
2180
|
-
bounds: compactBounds(node.bounds),
|
|
2181
|
-
childCount: extra.childCount,
|
|
2182
|
-
};
|
|
2183
|
-
}
|
|
2184
|
-
|
|
2185
|
-
function compactUiaTree(xml, options = {}) {
|
|
2186
|
-
const compactOptions = compactTreeOptions(options);
|
|
2187
|
-
const text = String(xml || '');
|
|
2188
|
-
const viewport = parseUiaViewport(text);
|
|
2189
|
-
const result = {
|
|
2190
|
-
ok: true,
|
|
2191
|
-
source: 'uiautomator',
|
|
2192
|
-
compact: true,
|
|
2193
|
-
rawBytes: Buffer.byteLength(text, 'utf8'),
|
|
2194
|
-
viewport,
|
|
2195
|
-
options: compactTreeResultOptions(compactOptions),
|
|
2196
|
-
nodes: [],
|
|
2197
|
-
scannedNodes: 0,
|
|
2198
|
-
matchedNodes: 0,
|
|
2199
|
-
truncated: false,
|
|
2200
|
-
updatedAtMs: Date.now(),
|
|
2201
|
-
};
|
|
2202
|
-
|
|
2203
|
-
const tokenRegex = /<\/node>|<node\b[^>]*\/?>/g;
|
|
2204
|
-
let depth = -1;
|
|
2205
|
-
let token;
|
|
2206
|
-
while ((token = tokenRegex.exec(text)) !== null) {
|
|
2207
|
-
const tag = token[0];
|
|
2208
|
-
if (tag.startsWith('</node')) {
|
|
2209
|
-
depth = Math.max(-1, depth - 1);
|
|
2210
|
-
continue;
|
|
2211
|
-
}
|
|
2212
|
-
|
|
2213
|
-
depth += 1;
|
|
2214
|
-
result.scannedNodes += 1;
|
|
2215
|
-
const attrs = parseXmlAttributes(tag);
|
|
2216
|
-
const node = compactUiaNode(attrs, depth, result.scannedNodes);
|
|
2217
|
-
if (depth <= compactOptions.maxDepth && uiaNodeMatchesCompactFilters(node, compactOptions, viewport)) {
|
|
2218
|
-
result.matchedNodes += 1;
|
|
2219
|
-
if (result.nodes.length >= compactOptions.maxNodes) {
|
|
2220
|
-
result.truncated = true;
|
|
2221
|
-
break;
|
|
2222
|
-
}
|
|
2223
|
-
result.nodes.push(node);
|
|
2224
|
-
}
|
|
2225
|
-
if (tag.endsWith('/>')) {
|
|
2226
|
-
depth = Math.max(-1, depth - 1);
|
|
2227
|
-
}
|
|
2228
|
-
}
|
|
2229
|
-
|
|
2230
|
-
return result;
|
|
2231
|
-
}
|
|
2232
|
-
|
|
2233
|
-
function compactUiaNode(attrs, depth, sequence) {
|
|
2234
|
-
return {
|
|
2235
|
-
depth,
|
|
2236
|
-
sequence,
|
|
2237
|
-
className: attrs.class || '',
|
|
2238
|
-
resourceId: attrs['resource-id'] || null,
|
|
2239
|
-
text: attrs.text || null,
|
|
2240
|
-
contentDescription: attrs['content-desc'] || null,
|
|
2241
|
-
packageName: attrs.package || null,
|
|
2242
|
-
enabled: attrs.enabled !== 'false',
|
|
2243
|
-
clickable: attrs.clickable === 'true',
|
|
2244
|
-
focusable: attrs.focusable === 'true',
|
|
2245
|
-
focused: attrs.focused === 'true',
|
|
2246
|
-
selected: attrs.selected === 'true',
|
|
2247
|
-
scrollable: attrs.scrollable === 'true',
|
|
2248
|
-
checked: attrs.checked === 'true',
|
|
2249
|
-
bounds: compactBounds(parseUiaBounds(attrs.bounds)),
|
|
2250
|
-
};
|
|
2251
|
-
}
|
|
2252
|
-
|
|
2253
|
-
function uiaNodeMatchesCompactFilters(node, options, viewport) {
|
|
2254
|
-
if (options.visibleOnly && !boundsInViewport(node.bounds, viewport)) return false;
|
|
2255
|
-
return compactFiltersMatch({
|
|
2256
|
-
text: compactString(node.text || node.contentDescription || ''),
|
|
2257
|
-
resource: compactString(node.resourceId || ''),
|
|
2258
|
-
className: compactString(node.className || ''),
|
|
2259
|
-
}, options);
|
|
2260
|
-
}
|
|
2261
|
-
|
|
2262
|
-
function compactFiltersMatch(values, options) {
|
|
2263
|
-
if (options.textFilter && !values.text.includes(options.textFilter)) return false;
|
|
2264
|
-
if (options.resourceIdFilter && !values.resource.includes(options.resourceIdFilter)) return false;
|
|
2265
|
-
if (options.classFilter && !values.className.includes(options.classFilter)) return false;
|
|
2266
|
-
return true;
|
|
2267
|
-
}
|
|
2268
|
-
|
|
2269
|
-
function compactTreeResultOptions(options) {
|
|
2270
|
-
return {
|
|
2271
|
-
textFilter: options.textFilter || undefined,
|
|
2272
|
-
resourceIdFilter: options.resourceIdFilter || undefined,
|
|
2273
|
-
classFilter: options.classFilter || undefined,
|
|
2274
|
-
visibleOnly: options.visibleOnly || undefined,
|
|
2275
|
-
maxNodes: options.maxNodes,
|
|
2276
|
-
maxDepth: Number.isFinite(options.maxDepth) ? options.maxDepth : undefined,
|
|
2277
|
-
};
|
|
2278
|
-
}
|
|
2279
|
-
|
|
2280
|
-
function parseXmlAttributes(tag) {
|
|
2281
|
-
const attrs = {};
|
|
2282
|
-
const attrRegex = /([A-Za-z0-9_:-]+)="([^"]*)"/g;
|
|
2283
|
-
let match;
|
|
2284
|
-
while ((match = attrRegex.exec(String(tag || ''))) !== null) {
|
|
2285
|
-
attrs[match[1]] = decodeXmlAttribute(match[2]);
|
|
2286
|
-
}
|
|
2287
|
-
return attrs;
|
|
2288
|
-
}
|
|
2289
|
-
|
|
2290
|
-
function decodeXmlAttribute(value) {
|
|
2291
|
-
return String(value || '')
|
|
2292
|
-
.replace(/"/g, '"')
|
|
2293
|
-
.replace(/'/g, "'")
|
|
2294
|
-
.replace(/</g, '<')
|
|
2295
|
-
.replace(/>/g, '>')
|
|
2296
|
-
.replace(/&/g, '&');
|
|
2297
|
-
}
|
|
2298
|
-
|
|
2299
|
-
function parseUiaBounds(value) {
|
|
2300
|
-
const match = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(String(value || ''));
|
|
2301
|
-
if (!match) return null;
|
|
2302
|
-
const left = Number(match[1]);
|
|
2303
|
-
const top = Number(match[2]);
|
|
2304
|
-
const right = Number(match[3]);
|
|
2305
|
-
const bottom = Number(match[4]);
|
|
2306
|
-
return {
|
|
2307
|
-
left,
|
|
2308
|
-
top,
|
|
2309
|
-
right,
|
|
2310
|
-
bottom,
|
|
2311
|
-
width: right - left,
|
|
2312
|
-
height: bottom - top,
|
|
2313
|
-
};
|
|
2314
|
-
}
|
|
2315
|
-
|
|
2316
|
-
function boundsInViewport(bounds, viewport) {
|
|
2317
|
-
if (!bounds) return false;
|
|
2318
|
-
const width = Number(bounds.width ?? bounds.right - bounds.left);
|
|
2319
|
-
const height = Number(bounds.height ?? bounds.bottom - bounds.top);
|
|
2320
|
-
if (width <= 0 || height <= 0) return false;
|
|
2321
|
-
if (!viewport) return true;
|
|
2322
|
-
const centerX = (Number(bounds.left) + Number(bounds.right)) / 2;
|
|
2323
|
-
const centerY = (Number(bounds.top) + Number(bounds.bottom)) / 2;
|
|
2324
|
-
return centerX >= Number(viewport.left) &&
|
|
2325
|
-
centerX <= Number(viewport.right) &&
|
|
2326
|
-
centerY >= Number(viewport.top) &&
|
|
2327
|
-
centerY <= Number(viewport.bottom);
|
|
2328
|
-
}
|
|
2329
|
-
|
|
2330
|
-
function compactBounds(bounds) {
|
|
2331
|
-
if (!bounds) return null;
|
|
2332
|
-
return {
|
|
2333
|
-
left: Number(bounds.left),
|
|
2334
|
-
top: Number(bounds.top),
|
|
2335
|
-
right: Number(bounds.right),
|
|
2336
|
-
bottom: Number(bounds.bottom),
|
|
2337
|
-
width: Number(bounds.width ?? bounds.right - bounds.left),
|
|
2338
|
-
height: Number(bounds.height ?? bounds.bottom - bounds.top),
|
|
2339
|
-
};
|
|
2340
|
-
}
|
|
2341
|
-
|
|
2342
|
-
function normalizeFilter(value) {
|
|
2343
|
-
return String(value || '').trim().toLowerCase();
|
|
2344
|
-
}
|
|
2345
|
-
|
|
2346
|
-
function compactString(value) {
|
|
2347
|
-
return String(value || '').toLowerCase();
|
|
2348
|
-
}
|
|
2349
|
-
|
|
2350
|
-
function boundedInteger(value, fallback, min, max) {
|
|
2351
|
-
if (value === undefined || value === null || value === '') return fallback;
|
|
2352
|
-
const number = Number(value);
|
|
2353
|
-
if (!Number.isFinite(number)) return fallback;
|
|
2354
|
-
return Math.max(min, Math.min(Math.floor(number), max));
|
|
2355
|
-
}
|
|
2356
|
-
|
|
2357
|
-
async function screenshot(ctx, outFile, options = {}) {
|
|
2358
|
-
const foreground = await foregroundWindow(ctx);
|
|
2359
|
-
const resolvedPath = await adbBinaryToFile(ctx, ['exec-out', 'screencap', '-p'], outFile);
|
|
2360
|
-
const size = pngSize(resolvedPath);
|
|
2361
|
-
const result = {
|
|
2362
|
-
ok: true,
|
|
2363
|
-
transport: 'adb',
|
|
2364
|
-
mimeType: 'image/png',
|
|
2365
|
-
path: resolvedPath,
|
|
2366
|
-
width: size.width,
|
|
2367
|
-
height: size.height,
|
|
2368
|
-
artifact: {
|
|
2369
|
-
path: resolvedPath,
|
|
2370
|
-
generatedDefault: !options.outFile,
|
|
2371
|
-
directory: path.dirname(resolvedPath),
|
|
2372
|
-
},
|
|
2373
|
-
foreground,
|
|
2374
|
-
};
|
|
2375
|
-
if (result.artifact.generatedDefault) {
|
|
2376
|
-
result.artifact.retention = await pruneGeneratedArtifacts({
|
|
2377
|
-
directory: result.artifact.directory,
|
|
2378
|
-
prefix: options.artifactPrefix || 'ai_app_bridge_screenshot',
|
|
2379
|
-
extension: 'png',
|
|
2380
|
-
currentPath: resolvedPath,
|
|
2381
|
-
});
|
|
2382
|
-
}
|
|
2383
|
-
if (ctx.packageName) {
|
|
2384
|
-
result.targetPackageName = ctx.packageName;
|
|
2385
|
-
}
|
|
2386
|
-
if (ctx.explicitPackageName && foreground.packageName) {
|
|
2387
|
-
result.foregroundMatchesPackage = foreground.packageName === ctx.packageName;
|
|
2388
|
-
if (!result.foregroundMatchesPackage) {
|
|
2389
|
-
result.ok = false;
|
|
2390
|
-
result.error = 'foreground_package_mismatch';
|
|
2391
|
-
result.warning = `screenshot captured foreground package ${foreground.packageName}, not requested package ${ctx.packageName}`;
|
|
2392
|
-
}
|
|
2393
|
-
}
|
|
2394
|
-
return result;
|
|
2395
|
-
}
|
|
2396
|
-
|
|
2397
|
-
function screenshotOutputPath(options = {}, prefix = 'ai_app_bridge_screenshot') {
|
|
2398
|
-
if (options.outFile) return options.outFile;
|
|
2399
|
-
return defaultArtifactPath(prefix, 'png', { artifactDir: options.artifactDir });
|
|
2400
|
-
}
|
|
2401
|
-
|
|
2402
|
-
async function pruneGeneratedArtifacts(options = {}) {
|
|
2403
|
-
const keep = generatedArtifactRetention;
|
|
2404
|
-
const result = {
|
|
2405
|
-
keep,
|
|
2406
|
-
matched: 0,
|
|
2407
|
-
deleted: 0,
|
|
2408
|
-
};
|
|
2409
|
-
const directory = path.resolve(options.directory || process.cwd());
|
|
2410
|
-
const prefix = sanitizeArtifactName(options.prefix || 'artifact');
|
|
2411
|
-
const extension = sanitizeArtifactExtension(options.extension || 'bin');
|
|
2412
|
-
const currentPath = options.currentPath ? path.resolve(options.currentPath) : '';
|
|
2413
|
-
const pattern = new RegExp(`^${escapeRegExp(prefix)}-\\d{8}-\\d{6}-\\d{3}-\\d+-[a-z0-9]+\\.${escapeRegExp(extension)}$`);
|
|
2414
|
-
|
|
2415
|
-
let entries;
|
|
2416
|
-
try {
|
|
2417
|
-
entries = await fs.promises.readdir(directory, { withFileTypes: true });
|
|
2418
|
-
} catch (error) {
|
|
2419
|
-
return {
|
|
2420
|
-
...result,
|
|
2421
|
-
error: firstErrorLine(error),
|
|
2422
|
-
};
|
|
2423
|
-
}
|
|
2424
|
-
|
|
2425
|
-
const files = [];
|
|
2426
|
-
for (const entry of entries) {
|
|
2427
|
-
if (!entry.isFile() || !pattern.test(entry.name)) {
|
|
2428
|
-
continue;
|
|
2429
|
-
}
|
|
2430
|
-
const filePath = path.join(directory, entry.name);
|
|
2431
|
-
try {
|
|
2432
|
-
const stat = await fs.promises.stat(filePath);
|
|
2433
|
-
files.push({
|
|
2434
|
-
name: entry.name,
|
|
2435
|
-
path: path.resolve(filePath),
|
|
2436
|
-
mtimeMs: stat.mtimeMs,
|
|
2437
|
-
});
|
|
2438
|
-
} catch (_) {
|
|
2439
|
-
// Ignore files that disappear while pruning.
|
|
2440
|
-
}
|
|
2441
|
-
}
|
|
2442
|
-
|
|
2443
|
-
result.matched = files.length;
|
|
2444
|
-
if (files.length <= keep) {
|
|
2445
|
-
return result;
|
|
2446
|
-
}
|
|
2447
|
-
|
|
2448
|
-
files.sort((left, right) => (right.mtimeMs - left.mtimeMs) || right.name.localeCompare(left.name));
|
|
2449
|
-
const keepSet = new Set();
|
|
2450
|
-
if (currentPath) {
|
|
2451
|
-
keepSet.add(currentPath);
|
|
2452
|
-
}
|
|
2453
|
-
for (const file of files) {
|
|
2454
|
-
if (keepSet.size >= keep) {
|
|
2455
|
-
break;
|
|
2456
|
-
}
|
|
2457
|
-
keepSet.add(file.path);
|
|
2458
|
-
}
|
|
2459
|
-
|
|
2460
|
-
for (const file of files) {
|
|
2461
|
-
if (keepSet.has(file.path)) {
|
|
2462
|
-
continue;
|
|
2463
|
-
}
|
|
2464
|
-
try {
|
|
2465
|
-
await fs.promises.rm(file.path, { force: true });
|
|
2466
|
-
result.deleted += 1;
|
|
2467
|
-
} catch (_) {
|
|
2468
|
-
// Pruning is best-effort and should not make screenshot capture fail.
|
|
2469
|
-
}
|
|
2470
|
-
}
|
|
2471
|
-
return result;
|
|
2472
|
-
}
|
|
2473
|
-
|
|
2474
|
-
function escapeRegExp(value) {
|
|
2475
|
-
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2476
|
-
}
|
|
2477
|
-
|
|
2478
|
-
function pngSize(filePath) {
|
|
2479
|
-
const bytes = fs.readFileSync(filePath);
|
|
2480
|
-
if (bytes.length < 24) return { width: 0, height: 0 };
|
|
2481
|
-
return {
|
|
2482
|
-
width: bytes.readUInt32BE(16),
|
|
2483
|
-
height: bytes.readUInt32BE(20),
|
|
2484
|
-
};
|
|
2485
|
-
}
|
|
2486
|
-
|
|
2487
|
-
async function foregroundWindow(ctx) {
|
|
2488
|
-
try {
|
|
2489
|
-
const result = await adb(ctx, ['shell', 'dumpsys', 'window']);
|
|
2490
|
-
return parseForegroundWindow(result.stdout);
|
|
2491
|
-
} catch (error) {
|
|
2492
|
-
return {
|
|
2493
|
-
ok: false,
|
|
2494
|
-
error: 'foreground_probe_failed',
|
|
2495
|
-
message: firstErrorLine(error),
|
|
2496
|
-
};
|
|
2497
|
-
}
|
|
2498
|
-
}
|
|
2499
|
-
|
|
2500
|
-
function parseForegroundWindow(raw) {
|
|
2501
|
-
const lines = String(raw || '').split(/\r?\n/);
|
|
2502
|
-
const markers = [
|
|
2503
|
-
'mCurrentFocus',
|
|
2504
|
-
'mTopResumedActivity',
|
|
2505
|
-
'mResumedActivity',
|
|
2506
|
-
'mFocusedApp',
|
|
2507
|
-
];
|
|
2508
|
-
for (const marker of markers) {
|
|
2509
|
-
const line = lines.find((item) => item.includes(marker));
|
|
2510
|
-
if (!line) continue;
|
|
2511
|
-
const component = parseComponentFromWindowLine(line);
|
|
2512
|
-
if (!component) continue;
|
|
2513
|
-
return {
|
|
2514
|
-
ok: true,
|
|
2515
|
-
source: marker,
|
|
2516
|
-
packageName: component.packageName,
|
|
2517
|
-
activity: component.activity,
|
|
2518
|
-
component: component.component,
|
|
2519
|
-
raw: line.trim(),
|
|
2520
|
-
};
|
|
2521
|
-
}
|
|
2522
|
-
return {
|
|
2523
|
-
ok: false,
|
|
2524
|
-
error: 'foreground_not_found',
|
|
2525
|
-
};
|
|
2526
|
-
}
|
|
2527
|
-
|
|
2528
|
-
function parseComponentFromWindowLine(line) {
|
|
2529
|
-
const componentRegex = /([A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+)\/(\.?[A-Za-z0-9_.$]+(?:\.[A-Za-z0-9_.$]+)*)/g;
|
|
2530
|
-
let match;
|
|
2531
|
-
let lastMatch = null;
|
|
2532
|
-
while ((match = componentRegex.exec(line)) !== null) {
|
|
2533
|
-
lastMatch = match;
|
|
2534
|
-
}
|
|
2535
|
-
if (!lastMatch) return null;
|
|
2536
|
-
const packageName = lastMatch[1];
|
|
2537
|
-
const rawActivity = lastMatch[2];
|
|
2538
|
-
const activity = rawActivity.startsWith('.') ? `${packageName}${rawActivity}` : rawActivity;
|
|
2539
|
-
return {
|
|
2540
|
-
packageName,
|
|
2541
|
-
activity,
|
|
2542
|
-
component: `${packageName}/${rawActivity}`,
|
|
2543
|
-
};
|
|
2544
|
-
}
|
|
2545
|
-
|
|
2546
|
-
async function tap(ctx, x, y) {
|
|
2547
|
-
await adb(ctx, ['shell', 'input', 'tap', String(x), String(y)]);
|
|
2548
|
-
return { ok: true, transport: 'adb', x, y };
|
|
2549
|
-
}
|
|
2550
|
-
|
|
2551
|
-
async function tapText(ctx, targetText, options = {}) {
|
|
2552
|
-
const tree = await bridgeGet(ctx, '/v1/view/tree');
|
|
2553
|
-
const bridgeMatch = findTappableNodeByText(tree, targetText);
|
|
2554
|
-
const node = bridgeMatch.node;
|
|
2555
|
-
if (node?.bounds) {
|
|
2556
|
-
let x = Math.round((node.bounds.left + node.bounds.right) / 2);
|
|
2557
|
-
let y = Math.round((node.bounds.top + node.bounds.bottom) / 2);
|
|
2558
|
-
let keyboard = null;
|
|
2559
|
-
if (!booleanOption(options.noAutoHideKeyboard)) {
|
|
2560
|
-
keyboard = await maybeHideKeyboardForPoint(ctx, { x, y }, bridgeMatch.viewport);
|
|
2561
|
-
if (keyboard.decision?.dismiss && !keyboard.dismissed) {
|
|
2562
|
-
return {
|
|
2563
|
-
ok: false,
|
|
2564
|
-
error: 'keyboard_obscures_target',
|
|
2565
|
-
targetText,
|
|
2566
|
-
source: 'bridge-tree',
|
|
2567
|
-
windowType: bridgeMatch.windowType,
|
|
2568
|
-
x,
|
|
2569
|
-
y,
|
|
2570
|
-
keyboard,
|
|
2571
|
-
};
|
|
2572
|
-
}
|
|
2573
|
-
if (keyboard.dismissed) {
|
|
2574
|
-
const refreshedMatch = findTappableNodeByText(await bridgeGet(ctx, '/v1/view/tree'), targetText);
|
|
2575
|
-
if (refreshedMatch.node?.bounds) {
|
|
2576
|
-
x = Math.round((refreshedMatch.node.bounds.left + refreshedMatch.node.bounds.right) / 2);
|
|
2577
|
-
y = Math.round((refreshedMatch.node.bounds.top + refreshedMatch.node.bounds.bottom) / 2);
|
|
2578
|
-
}
|
|
2579
|
-
}
|
|
2580
|
-
}
|
|
2581
|
-
await tap(ctx, x, y);
|
|
2582
|
-
return {
|
|
2583
|
-
ok: true,
|
|
2584
|
-
transport: 'adb',
|
|
2585
|
-
targetText,
|
|
2586
|
-
source: 'bridge-tree',
|
|
2587
|
-
windowType: bridgeMatch.windowType,
|
|
2588
|
-
x,
|
|
2589
|
-
y,
|
|
2590
|
-
keyboard,
|
|
2591
|
-
};
|
|
2592
|
-
}
|
|
2593
|
-
|
|
2594
|
-
let xml = await uiaTree(ctx);
|
|
2595
|
-
let uiaNode = findUiaNodeByText(xml, targetText);
|
|
2596
|
-
if (!uiaNode) {
|
|
2597
|
-
const flutterTap = await tryTapFlutterText(ctx, targetText, options);
|
|
2598
|
-
if (flutterTap) {
|
|
2599
|
-
return flutterTap;
|
|
2600
|
-
}
|
|
2601
|
-
if (bridgeMatch.rejected) {
|
|
2602
|
-
return {
|
|
2603
|
-
ok: false,
|
|
2604
|
-
error: 'bridge_tree_node_not_tappable',
|
|
2605
|
-
targetText,
|
|
2606
|
-
source: 'bridge-tree',
|
|
2607
|
-
reason: bridgeMatch.rejected.reason,
|
|
2608
|
-
bounds: bridgeMatch.rejected.node.bounds || null,
|
|
2609
|
-
viewport: bridgeMatch.rejected.viewport || null,
|
|
2610
|
-
};
|
|
2611
|
-
}
|
|
2612
|
-
throw new Error(`text not found in Android bridge tree, UIAutomator tree, or Flutter operable tree: ${targetText}`);
|
|
2613
|
-
}
|
|
2614
|
-
let x = Math.round((uiaNode.left + uiaNode.right) / 2);
|
|
2615
|
-
let y = Math.round((uiaNode.top + uiaNode.bottom) / 2);
|
|
2616
|
-
let keyboard = null;
|
|
2617
|
-
if (!booleanOption(options.noAutoHideKeyboard)) {
|
|
2618
|
-
keyboard = await maybeHideKeyboardForPoint(ctx, { x, y }, parseUiaViewport(xml));
|
|
2619
|
-
if (keyboard.decision?.dismiss && !keyboard.dismissed) {
|
|
2620
|
-
return {
|
|
2621
|
-
ok: false,
|
|
2622
|
-
error: 'keyboard_obscures_target',
|
|
2623
|
-
targetText,
|
|
2624
|
-
source: 'uiautomator',
|
|
2625
|
-
x,
|
|
2626
|
-
y,
|
|
2627
|
-
keyboard,
|
|
2628
|
-
};
|
|
2629
|
-
}
|
|
2630
|
-
if (keyboard.dismissed) {
|
|
2631
|
-
xml = await uiaTree(ctx);
|
|
2632
|
-
const refreshedNode = findUiaNodeByText(xml, targetText);
|
|
2633
|
-
if (refreshedNode) {
|
|
2634
|
-
uiaNode = refreshedNode;
|
|
2635
|
-
x = Math.round((uiaNode.left + uiaNode.right) / 2);
|
|
2636
|
-
y = Math.round((uiaNode.top + uiaNode.bottom) / 2);
|
|
2637
|
-
}
|
|
2638
|
-
}
|
|
2639
|
-
}
|
|
2640
|
-
await tap(ctx, x, y);
|
|
2641
|
-
return { ok: true, transport: 'adb', targetText, source: 'uiautomator', x, y, keyboard };
|
|
2642
|
-
}
|
|
2643
|
-
|
|
2644
|
-
function findTappableNodeByText(tree, targetText) {
|
|
2645
|
-
const roots = [];
|
|
2646
|
-
const windows = Array.isArray(tree?.windows) ? tree.windows : [];
|
|
2647
|
-
for (const windowInfo of windows.slice().reverse()) {
|
|
2648
|
-
if (windowInfo?.root) {
|
|
2649
|
-
roots.push({
|
|
2650
|
-
root: windowInfo.root,
|
|
2651
|
-
viewport: windowInfo.bounds || windowInfo.root.bounds || null,
|
|
2652
|
-
windowType: windowInfo.type || 'window',
|
|
2653
|
-
});
|
|
2654
|
-
}
|
|
2655
|
-
}
|
|
2656
|
-
if (tree?.root) {
|
|
2657
|
-
roots.push({
|
|
2658
|
-
root: tree.root,
|
|
2659
|
-
viewport: tree.root.bounds || null,
|
|
2660
|
-
windowType: 'activity',
|
|
2661
|
-
});
|
|
2662
|
-
}
|
|
2663
|
-
|
|
2664
|
-
let rejected = null;
|
|
2665
|
-
for (const rootInfo of roots) {
|
|
2666
|
-
const result = findNodeByText(rootInfo.root, targetText, rootInfo.viewport);
|
|
2667
|
-
if (result.node) {
|
|
2668
|
-
return {
|
|
2669
|
-
node: result.node,
|
|
2670
|
-
windowType: rootInfo.windowType,
|
|
2671
|
-
viewport: rootInfo.viewport,
|
|
2672
|
-
rejected,
|
|
2673
|
-
};
|
|
2674
|
-
}
|
|
2675
|
-
rejected = rejected || result.rejected;
|
|
2676
|
-
}
|
|
2677
|
-
return { node: null, rejected };
|
|
2678
|
-
}
|
|
2679
|
-
|
|
2680
|
-
function findNodeByText(node, targetText, viewport = null) {
|
|
2681
|
-
if (!node) return { node: null, rejected: null };
|
|
2682
|
-
if (node.text === targetText || node.contentDescription === targetText) {
|
|
2683
|
-
const state = nodeTapState(node, viewport);
|
|
2684
|
-
if (state.ok) {
|
|
2685
|
-
return { node, rejected: null };
|
|
2686
|
-
}
|
|
2687
|
-
return {
|
|
2688
|
-
node: null,
|
|
2689
|
-
rejected: {
|
|
2690
|
-
node,
|
|
2691
|
-
viewport,
|
|
2692
|
-
reason: state.reason,
|
|
2693
|
-
},
|
|
2694
|
-
};
|
|
2695
|
-
}
|
|
2696
|
-
let rejected = null;
|
|
2697
|
-
for (const child of node.children || []) {
|
|
2698
|
-
const found = findNodeByText(child, targetText, viewport);
|
|
2699
|
-
if (found.node) return found;
|
|
2700
|
-
rejected = rejected || found.rejected;
|
|
2701
|
-
}
|
|
2702
|
-
return { node: null, rejected };
|
|
2703
|
-
}
|
|
2704
|
-
|
|
2705
|
-
function nodeTapState(node, viewport) {
|
|
2706
|
-
const bounds = node?.bounds;
|
|
2707
|
-
if (!bounds) return { ok: false, reason: 'missing_bounds' };
|
|
2708
|
-
if (node.visible === false || node.effectiveVisible === false) {
|
|
2709
|
-
return { ok: false, reason: 'not_effectively_visible' };
|
|
2710
|
-
}
|
|
2711
|
-
const width = Number(bounds.width ?? bounds.right - bounds.left);
|
|
2712
|
-
const height = Number(bounds.height ?? bounds.bottom - bounds.top);
|
|
2713
|
-
if (width <= 0 || height <= 0) {
|
|
2714
|
-
return { ok: false, reason: 'empty_bounds' };
|
|
2715
|
-
}
|
|
2716
|
-
if (!viewport) return { ok: true };
|
|
2717
|
-
const centerX = (Number(bounds.left) + Number(bounds.right)) / 2;
|
|
2718
|
-
const centerY = (Number(bounds.top) + Number(bounds.bottom)) / 2;
|
|
2719
|
-
if (
|
|
2720
|
-
centerX < Number(viewport.left) ||
|
|
2721
|
-
centerX > Number(viewport.right) ||
|
|
2722
|
-
centerY < Number(viewport.top) ||
|
|
2723
|
-
centerY > Number(viewport.bottom)
|
|
2724
|
-
) {
|
|
2725
|
-
return { ok: false, reason: 'center_outside_viewport' };
|
|
2726
|
-
}
|
|
2727
|
-
return { ok: true };
|
|
2728
|
-
}
|
|
2729
|
-
|
|
2730
|
-
function findUiaNodeByText(xml, targetText) {
|
|
2731
|
-
const escaped = escapeRegExp(targetText);
|
|
2732
|
-
const nodeRegex = /<node\b[^>]*>/g;
|
|
2733
|
-
let match;
|
|
2734
|
-
while ((match = nodeRegex.exec(xml)) !== null) {
|
|
2735
|
-
const nodeXml = match[0];
|
|
2736
|
-
const textMatch = new RegExp(`\\btext="${escaped}"`).test(nodeXml);
|
|
2737
|
-
const descMatch = new RegExp(`\\bcontent-desc="${escaped}"`).test(nodeXml);
|
|
2738
|
-
if (!textMatch && !descMatch) continue;
|
|
2739
|
-
const boundsMatch = /\bbounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/.exec(nodeXml);
|
|
2740
|
-
if (!boundsMatch) continue;
|
|
2741
|
-
return {
|
|
2742
|
-
left: Number(boundsMatch[1]),
|
|
2743
|
-
top: Number(boundsMatch[2]),
|
|
2744
|
-
right: Number(boundsMatch[3]),
|
|
2745
|
-
bottom: Number(boundsMatch[4]),
|
|
2746
|
-
};
|
|
2747
|
-
}
|
|
2748
|
-
return null;
|
|
2749
|
-
}
|
|
2750
|
-
|
|
2751
|
-
function parseUiaViewport(xml) {
|
|
2752
|
-
const match = /<node\b[^>]*\bbounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/.exec(String(xml || ''));
|
|
2753
|
-
if (!match) return null;
|
|
2754
|
-
const left = Number(match[1]);
|
|
2755
|
-
const top = Number(match[2]);
|
|
2756
|
-
const right = Number(match[3]);
|
|
2757
|
-
const bottom = Number(match[4]);
|
|
2758
|
-
return {
|
|
2759
|
-
left,
|
|
2760
|
-
top,
|
|
2761
|
-
right,
|
|
2762
|
-
bottom,
|
|
2763
|
-
width: right - left,
|
|
2764
|
-
height: bottom - top,
|
|
2765
|
-
};
|
|
2766
|
-
}
|
|
2767
|
-
|
|
2768
|
-
async function tapUiaText(ctx, targetText, options = {}) {
|
|
2769
|
-
const node = findUiaNodeByAny(await uiaTree(ctx), {
|
|
2770
|
-
texts: [targetText],
|
|
2771
|
-
exact: booleanOption(options.exact),
|
|
2772
|
-
});
|
|
2773
|
-
if (!node) {
|
|
2774
|
-
throw new Error(`text not found in UIAutomator tree: ${targetText}`);
|
|
2775
|
-
}
|
|
2776
|
-
const x = Math.round((node.left + node.right) / 2);
|
|
2777
|
-
const y = Math.round((node.top + node.bottom) / 2);
|
|
2778
|
-
await tap(ctx, x, y);
|
|
2779
|
-
return { ok: true, transport: 'adb', source: 'uiautomator', targetText, x, y, matched: node.matched };
|
|
2780
|
-
}
|
|
2781
|
-
|
|
2782
|
-
async function permissionDialog(ctx, options) {
|
|
2783
|
-
const texts = splitCsv(options.targetText || options.buttonText || options.allowText);
|
|
2784
|
-
const resourceIds = splitCsv(options.resourceId || options.resourceIds);
|
|
2785
|
-
const candidates = texts.length ? texts : defaultPermissionAllowTexts();
|
|
2786
|
-
const ids = resourceIds.length ? resourceIds : defaultPermissionAllowResourceIds();
|
|
2787
|
-
const attempts = Number(options.attempts || 8);
|
|
2788
|
-
const intervalMs = Number(options.intervalMs || 500);
|
|
2789
|
-
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
2790
|
-
const xml = await uiaTree(ctx);
|
|
2791
|
-
const node = findUiaNodeByAny(xml, {
|
|
2792
|
-
resourceIds: ids,
|
|
2793
|
-
}) || findUiaNodeByAny(xml, {
|
|
2794
|
-
texts: candidates,
|
|
2795
|
-
exact: booleanOption(options.exact),
|
|
2796
|
-
requireClickable: true,
|
|
2797
|
-
});
|
|
2798
|
-
if (node) {
|
|
2799
|
-
const x = Math.round((node.left + node.right) / 2);
|
|
2800
|
-
const y = Math.round((node.top + node.bottom) / 2);
|
|
2801
|
-
await tap(ctx, x, y);
|
|
2802
|
-
return {
|
|
2803
|
-
ok: true,
|
|
2804
|
-
transport: 'adb',
|
|
2805
|
-
source: 'uiautomator',
|
|
2806
|
-
action: 'permission-dialog',
|
|
2807
|
-
attempt,
|
|
2808
|
-
x,
|
|
2809
|
-
y,
|
|
2810
|
-
matched: node.matched,
|
|
2811
|
-
};
|
|
2812
|
-
}
|
|
2813
|
-
await sleep(intervalMs);
|
|
2814
|
-
}
|
|
2815
|
-
return {
|
|
2816
|
-
ok: false,
|
|
2817
|
-
error: 'permission_dialog_allow_button_not_found',
|
|
2818
|
-
texts: candidates,
|
|
2819
|
-
resourceIds: ids,
|
|
2820
|
-
attempts,
|
|
2821
|
-
};
|
|
2822
|
-
}
|
|
2823
|
-
|
|
2824
|
-
function findUiaNodeByAny(xml, options) {
|
|
2825
|
-
const texts = (options.texts || []).map(String).filter(Boolean);
|
|
2826
|
-
const resourceIds = (options.resourceIds || []).map(String).filter(Boolean);
|
|
2827
|
-
const exact = Boolean(options.exact);
|
|
2828
|
-
const nodeRegex = /<node\b[^>]*>/g;
|
|
2829
|
-
let match;
|
|
2830
|
-
while ((match = nodeRegex.exec(xml)) !== null) {
|
|
2831
|
-
const nodeXml = match[0];
|
|
2832
|
-
const attrs = {
|
|
2833
|
-
text: xmlUnescape(readXmlAttribute(nodeXml, 'text')),
|
|
2834
|
-
contentDescription: xmlUnescape(readXmlAttribute(nodeXml, 'content-desc')),
|
|
2835
|
-
resourceId: xmlUnescape(readXmlAttribute(nodeXml, 'resource-id')),
|
|
2836
|
-
className: xmlUnescape(readXmlAttribute(nodeXml, 'class')),
|
|
2837
|
-
clickable: readXmlAttribute(nodeXml, 'clickable') === 'true',
|
|
2838
|
-
enabled: readXmlAttribute(nodeXml, 'enabled') !== 'false',
|
|
2839
|
-
};
|
|
2840
|
-
if (options.requireClickable && (!attrs.clickable || !attrs.enabled)) continue;
|
|
2841
|
-
const textMatch = texts.find((target) => {
|
|
2842
|
-
return [attrs.text, attrs.contentDescription].some((value) => {
|
|
2843
|
-
return exact ? value === target : value.includes(target);
|
|
2844
|
-
});
|
|
2845
|
-
});
|
|
2846
|
-
const resourceIdMatch = resourceIds.find((target) => {
|
|
2847
|
-
return exact ? attrs.resourceId === target : attrs.resourceId.includes(target);
|
|
2848
|
-
});
|
|
2849
|
-
if (!textMatch && !resourceIdMatch) continue;
|
|
2850
|
-
const boundsMatch = /\bbounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/.exec(nodeXml);
|
|
2851
|
-
if (!boundsMatch) continue;
|
|
2852
|
-
return {
|
|
2853
|
-
left: Number(boundsMatch[1]),
|
|
2854
|
-
top: Number(boundsMatch[2]),
|
|
2855
|
-
right: Number(boundsMatch[3]),
|
|
2856
|
-
bottom: Number(boundsMatch[4]),
|
|
2857
|
-
matched: {
|
|
2858
|
-
text: attrs.text,
|
|
2859
|
-
contentDescription: attrs.contentDescription,
|
|
2860
|
-
resourceId: attrs.resourceId,
|
|
2861
|
-
className: attrs.className,
|
|
2862
|
-
clickable: attrs.clickable,
|
|
2863
|
-
target: textMatch || resourceIdMatch,
|
|
2864
|
-
},
|
|
2865
|
-
};
|
|
2866
|
-
}
|
|
2867
|
-
return null;
|
|
2868
|
-
}
|
|
2869
|
-
|
|
2870
|
-
function readXmlAttribute(xml, name) {
|
|
2871
|
-
const match = new RegExp(`\\b${escapeRegExp(name)}="([^"]*)"`).exec(xml);
|
|
2872
|
-
return match ? match[1] : '';
|
|
2873
|
-
}
|
|
2874
|
-
|
|
2875
|
-
function xmlUnescape(value) {
|
|
2876
|
-
return String(value || '')
|
|
2877
|
-
.replace(/"/g, '"')
|
|
2878
|
-
.replace(/'/g, "'")
|
|
2879
|
-
.replace(/</g, '<')
|
|
2880
|
-
.replace(/>/g, '>')
|
|
2881
|
-
.replace(/&/g, '&');
|
|
2882
|
-
}
|
|
2883
|
-
|
|
2884
|
-
function defaultPermissionAllowTexts() {
|
|
2885
|
-
return [
|
|
2886
|
-
'Allow',
|
|
2887
|
-
'While using the app',
|
|
2888
|
-
'Only this time',
|
|
2889
|
-
'仅在使用该应用时允许',
|
|
2890
|
-
'使用应用时允许',
|
|
2891
|
-
'使用时允许',
|
|
2892
|
-
'仅本次允许',
|
|
2893
|
-
'仅本次使用时允许',
|
|
2894
|
-
'始终允许',
|
|
2895
|
-
];
|
|
2896
|
-
}
|
|
2897
|
-
|
|
2898
|
-
function defaultPermissionAllowResourceIds() {
|
|
2899
|
-
return [
|
|
2900
|
-
'permission_allow_button',
|
|
2901
|
-
'permission_allow_foreground_only_button',
|
|
2902
|
-
'permission_allow_one_time_button',
|
|
2903
|
-
'android:id/button1',
|
|
2904
|
-
];
|
|
2905
|
-
}
|
|
2906
|
-
|
|
2907
|
-
function escapeRegExp(value) {
|
|
2908
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2909
|
-
}
|
|
2910
|
-
|
|
2911
|
-
async function waitText(ctx, targetText, options = {}) {
|
|
2912
|
-
const timeoutSec = Number(options.timeoutSec || 10);
|
|
2913
|
-
const conditions = {
|
|
2914
|
-
requireTexts: splitCsv(options.requireText || options.requireTexts),
|
|
2915
|
-
absentTexts: splitCsv(options.absentText || options.absentTexts),
|
|
2916
|
-
requireActivity: options.requireActivity || options.activity || '',
|
|
2917
|
-
};
|
|
2918
|
-
const deadline = Date.now() + timeoutSec * 1000;
|
|
2919
|
-
let lastCheck = null;
|
|
2920
|
-
while (Date.now() < deadline) {
|
|
2921
|
-
const snapshot = await textSnapshot(ctx);
|
|
2922
|
-
lastCheck = waitTextConditionsMet(snapshot, targetText, conditions);
|
|
2923
|
-
if (lastCheck.ok) {
|
|
2924
|
-
return { ok: true, targetText, timeoutSec, conditions, matched: lastCheck };
|
|
2925
|
-
}
|
|
2926
|
-
await sleep(500);
|
|
2927
|
-
}
|
|
2928
|
-
return { ok: false, error: 'text_not_found', targetText, timeoutSec, conditions, lastCheck };
|
|
2929
|
-
}
|
|
2930
|
-
|
|
2931
|
-
async function flutterNodes(ctx) {
|
|
2932
|
-
const status = await bridgeGet(ctx, '/v1/status');
|
|
2933
|
-
return status.flutter?.layout?.operable || { ok: false, error: 'no_flutter_operable_tree' };
|
|
2934
|
-
}
|
|
2935
|
-
|
|
2936
|
-
async function flutterAction(ctx, payload) {
|
|
2937
|
-
const result = await bridgePost(ctx, '/v1/flutter/action', payload);
|
|
2938
|
-
return { ...result, transport: 'bridge', source: 'flutter-runtime-action', request: payload };
|
|
2939
|
-
}
|
|
2940
|
-
|
|
2941
|
-
async function tryTapFlutterText(ctx, targetText, options = {}) {
|
|
2942
|
-
try {
|
|
2943
|
-
return await tapFlutterText(ctx, targetText, options);
|
|
2944
|
-
} catch (_) {
|
|
2945
|
-
return null;
|
|
2946
|
-
}
|
|
2947
|
-
}
|
|
2948
|
-
|
|
2949
|
-
async function tapFlutterText(ctx, targetText, options = {}) {
|
|
2950
|
-
let operable = await flutterNodes(ctx);
|
|
2951
|
-
let node = findFlutterNode(operable, targetText, 'tap');
|
|
2952
|
-
if (!node) {
|
|
2953
|
-
throw new Error(`flutter tap node not found: ${targetText}`);
|
|
2954
|
-
}
|
|
2955
|
-
let point = flutterNodePoint(node.tap?.bounds || node.bounds, operable.viewport);
|
|
2956
|
-
let keyboard = null;
|
|
2957
|
-
if (!booleanOption(options.noAutoHideKeyboard)) {
|
|
2958
|
-
keyboard = await maybeHideKeyboardForPoint(ctx, point, flutterPhysicalViewport(operable.viewport));
|
|
2959
|
-
if (keyboard.decision?.dismiss && !keyboard.dismissed) {
|
|
2960
|
-
return {
|
|
2961
|
-
ok: false,
|
|
2962
|
-
error: 'keyboard_obscures_target',
|
|
2963
|
-
targetText,
|
|
2964
|
-
source: 'flutter-operable-tree',
|
|
2965
|
-
x: point.x,
|
|
2966
|
-
y: point.y,
|
|
2967
|
-
keyboard,
|
|
2968
|
-
};
|
|
2969
|
-
}
|
|
2970
|
-
if (keyboard.dismissed) {
|
|
2971
|
-
operable = await flutterNodes(ctx);
|
|
2972
|
-
const refreshedNode = findFlutterNode(operable, targetText, 'tap');
|
|
2973
|
-
if (refreshedNode) {
|
|
2974
|
-
node = refreshedNode;
|
|
2975
|
-
point = flutterNodePoint(node.tap?.bounds || node.bounds, operable.viewport);
|
|
2976
|
-
}
|
|
2977
|
-
}
|
|
2978
|
-
}
|
|
2979
|
-
await tap(ctx, point.x, point.y);
|
|
2980
|
-
return {
|
|
2981
|
-
ok: true,
|
|
2982
|
-
transport: 'adb',
|
|
2983
|
-
source: 'flutter-operable-tree',
|
|
2984
|
-
targetText,
|
|
2985
|
-
node,
|
|
2986
|
-
x: point.x,
|
|
2987
|
-
y: point.y,
|
|
2988
|
-
keyboard,
|
|
2989
|
-
};
|
|
2990
|
-
}
|
|
2991
|
-
|
|
2992
|
-
async function inputFlutterText(ctx, targetText, text) {
|
|
2993
|
-
const operable = await flutterNodes(ctx);
|
|
2994
|
-
const node = findFlutterNode(operable, targetText, 'input');
|
|
2995
|
-
if (!node) {
|
|
2996
|
-
throw new Error(`flutter input node not found: ${targetText}`);
|
|
2997
|
-
}
|
|
2998
|
-
const point = flutterNodePoint(node.input?.bounds || node.bounds, operable.viewport);
|
|
2999
|
-
const result = await flutterAction(ctx, { action: 'inputText', text, x: point.x, y: point.y });
|
|
3000
|
-
return {
|
|
3001
|
-
...result,
|
|
3002
|
-
source: 'flutter-operable-tree',
|
|
3003
|
-
targetText,
|
|
3004
|
-
text,
|
|
3005
|
-
node,
|
|
3006
|
-
x: point.x,
|
|
3007
|
-
y: point.y,
|
|
3008
|
-
};
|
|
3009
|
-
}
|
|
3010
|
-
|
|
3011
|
-
async function scrollFlutter(ctx, targetText) {
|
|
3012
|
-
const operable = await flutterNodes(ctx);
|
|
3013
|
-
const node = targetText
|
|
3014
|
-
? findFlutterNode(operable, targetText, 'scroll')
|
|
3015
|
-
: (operable.nodes || []).find((item) => (item.actions || []).includes('scroll') && item.scroll?.bounds);
|
|
3016
|
-
if (!node) {
|
|
3017
|
-
throw new Error(targetText ? `flutter scroll node not found: ${targetText}` : 'flutter scroll node not found');
|
|
3018
|
-
}
|
|
3019
|
-
const bounds = node.scroll?.bounds || node.bounds;
|
|
3020
|
-
const dpr = Number(operable.viewport?.devicePixelRatio || 1);
|
|
3021
|
-
const x = Math.round(((bounds.left + bounds.right) / 2) * dpr);
|
|
3022
|
-
const startY = Math.round((bounds.bottom - bounds.height * 0.2) * dpr);
|
|
3023
|
-
const endY = Math.round((bounds.top + bounds.height * 0.2) * dpr);
|
|
3024
|
-
await swipe(ctx, x, startY, x, endY, 600);
|
|
3025
|
-
return {
|
|
3026
|
-
ok: true,
|
|
3027
|
-
transport: 'adb',
|
|
3028
|
-
source: 'flutter-operable-tree',
|
|
3029
|
-
targetText,
|
|
3030
|
-
node,
|
|
3031
|
-
startX: x,
|
|
3032
|
-
startY,
|
|
3033
|
-
endX: x,
|
|
3034
|
-
endY,
|
|
3035
|
-
};
|
|
3036
|
-
}
|
|
3037
|
-
|
|
3038
|
-
function findFlutterNode(operable, targetText, action) {
|
|
3039
|
-
const nodes = Array.isArray(operable?.nodes) ? operable.nodes : [];
|
|
3040
|
-
const candidates = nodes.filter((node) => {
|
|
3041
|
-
const actions = Array.isArray(node.actions) ? node.actions : [];
|
|
3042
|
-
return actions.includes(action) && flutterNodeMatches(node, targetText);
|
|
3043
|
-
});
|
|
3044
|
-
return candidates.find((node) => node.text === targetText || node.value === targetText) || candidates[0] || null;
|
|
3045
|
-
}
|
|
3046
|
-
|
|
3047
|
-
function flutterNodeMatches(node, targetText) {
|
|
3048
|
-
const text = String(node.text || '');
|
|
3049
|
-
const value = String(node.value || '');
|
|
3050
|
-
return text === targetText || value === targetText || text.includes(targetText) || value.includes(targetText);
|
|
3051
|
-
}
|
|
3052
|
-
|
|
3053
|
-
function flutterNodePoint(bounds, viewport) {
|
|
3054
|
-
if (!bounds) throw new Error('flutter node has no bounds');
|
|
3055
|
-
const dpr = Number(viewport?.devicePixelRatio || 1);
|
|
3056
|
-
return {
|
|
3057
|
-
x: Math.round(Number(bounds.centerX ?? ((bounds.left + bounds.right) / 2)) * dpr),
|
|
3058
|
-
y: Math.round(Number(bounds.centerY ?? ((bounds.top + bounds.bottom) / 2)) * dpr),
|
|
3059
|
-
};
|
|
3060
|
-
}
|
|
3061
|
-
|
|
3062
|
-
function flutterPhysicalViewport(viewport) {
|
|
3063
|
-
const width = Number(viewport?.physicalWidth || viewport?.width || viewport?.logicalWidth || 0);
|
|
3064
|
-
const height = Number(viewport?.physicalHeight || viewport?.height || viewport?.logicalHeight || 0);
|
|
3065
|
-
if (!width || !height) return null;
|
|
3066
|
-
return {
|
|
3067
|
-
left: 0,
|
|
3068
|
-
top: 0,
|
|
3069
|
-
right: width,
|
|
3070
|
-
bottom: height,
|
|
3071
|
-
width,
|
|
3072
|
-
height,
|
|
3073
|
-
};
|
|
3074
|
-
}
|
|
3075
|
-
|
|
3076
|
-
async function h5Click(ctx, options) {
|
|
3077
|
-
const result = await h5Operation(ctx, 'click', h5TargetOptions(options));
|
|
3078
|
-
assertH5OperationOk(result, 'h5_click_failed');
|
|
3079
|
-
return result;
|
|
3080
|
-
}
|
|
3081
|
-
|
|
3082
|
-
async function h5Input(ctx, options) {
|
|
3083
|
-
const value = options.value ?? options.inputValue ?? options.text;
|
|
3084
|
-
if (value === undefined || value === null) {
|
|
3085
|
-
throw new Error('missing required option: value');
|
|
3086
|
-
}
|
|
3087
|
-
const result = await h5Operation(ctx, 'input', {
|
|
3088
|
-
...h5TargetOptions(options),
|
|
3089
|
-
value: String(value),
|
|
3090
|
-
});
|
|
3091
|
-
assertH5OperationOk(result, 'h5_input_failed');
|
|
3092
|
-
return result;
|
|
3093
|
-
}
|
|
3094
|
-
|
|
3095
|
-
async function h5Scroll(ctx, options) {
|
|
3096
|
-
const result = await h5Operation(ctx, 'scroll', {
|
|
3097
|
-
...h5TargetOptions(options),
|
|
3098
|
-
deltaX: Number(options.deltaX || 0),
|
|
3099
|
-
deltaY: Number(options.deltaY || options.delta || 480),
|
|
3100
|
-
});
|
|
3101
|
-
assertH5OperationOk(result, 'h5_scroll_failed');
|
|
3102
|
-
return result;
|
|
3103
|
-
}
|
|
3104
|
-
|
|
3105
|
-
async function h5Wait(ctx, options) {
|
|
3106
|
-
const timeoutSec = Number(options.timeoutSec || 10);
|
|
3107
|
-
const intervalMs = Number(options.intervalMs || 500);
|
|
3108
|
-
const deadline = Date.now() + timeoutSec * 1000;
|
|
3109
|
-
let lastResult = null;
|
|
3110
|
-
while (Date.now() <= deadline) {
|
|
3111
|
-
lastResult = await h5Operation(ctx, 'find', h5TargetOptions(options));
|
|
3112
|
-
if (lastResult.result?.ok) {
|
|
3113
|
-
return { ...lastResult, timeoutSec, intervalMs };
|
|
3114
|
-
}
|
|
3115
|
-
await sleep(intervalMs);
|
|
3116
|
-
}
|
|
3117
|
-
return {
|
|
3118
|
-
ok: false,
|
|
3119
|
-
error: 'h5_target_not_found',
|
|
3120
|
-
timeoutSec,
|
|
3121
|
-
intervalMs,
|
|
3122
|
-
lastResult,
|
|
3123
|
-
};
|
|
3124
|
-
}
|
|
3125
|
-
|
|
3126
|
-
async function h5Operation(ctx, action, params) {
|
|
3127
|
-
const response = await bridgePost(ctx, '/v1/h5/eval', {
|
|
3128
|
-
script: h5OperationScript(action, params),
|
|
3129
|
-
});
|
|
3130
|
-
return {
|
|
3131
|
-
...response,
|
|
3132
|
-
transport: 'bridge',
|
|
3133
|
-
source: 'native-webview-eval',
|
|
3134
|
-
action,
|
|
3135
|
-
request: params,
|
|
3136
|
-
result: normalizeH5EvalResult(response.result),
|
|
3137
|
-
};
|
|
3138
|
-
}
|
|
3139
|
-
|
|
3140
|
-
async function flutterH5Dom(ctx) {
|
|
3141
|
-
const response = await flutterAction(ctx, { action: 'h5Dom' });
|
|
3142
|
-
return { ...response, source: 'flutter-h5-adapter' };
|
|
3143
|
-
}
|
|
3144
|
-
|
|
3145
|
-
async function flutterH5Eval(ctx, params) {
|
|
3146
|
-
const response = await flutterAction(ctx, { action: 'h5Eval', script: params.script });
|
|
3147
|
-
return { ...response, source: 'flutter-h5-adapter' };
|
|
3148
|
-
}
|
|
3149
|
-
|
|
3150
|
-
async function flutterH5Click(ctx, options) {
|
|
3151
|
-
const result = await flutterH5Operation(ctx, 'click', h5TargetOptions(options));
|
|
3152
|
-
assertH5OperationOk(result, 'flutter_h5_click_failed');
|
|
3153
|
-
return result;
|
|
3154
|
-
}
|
|
3155
|
-
|
|
3156
|
-
async function flutterH5Input(ctx, options) {
|
|
3157
|
-
const value = options.value ?? options.inputValue ?? options.text;
|
|
3158
|
-
if (value === undefined || value === null) {
|
|
3159
|
-
throw new Error('missing required option: value');
|
|
3160
|
-
}
|
|
3161
|
-
const result = await flutterH5Operation(ctx, 'input', {
|
|
3162
|
-
...h5TargetOptions(options),
|
|
3163
|
-
value: String(value),
|
|
3164
|
-
});
|
|
3165
|
-
assertH5OperationOk(result, 'flutter_h5_input_failed');
|
|
3166
|
-
return result;
|
|
3167
|
-
}
|
|
3168
|
-
|
|
3169
|
-
async function flutterH5Scroll(ctx, options) {
|
|
3170
|
-
const result = await flutterH5Operation(ctx, 'scroll', {
|
|
3171
|
-
...h5TargetOptions(options),
|
|
3172
|
-
deltaX: Number(options.deltaX || 0),
|
|
3173
|
-
deltaY: Number(options.deltaY || options.delta || 480),
|
|
3174
|
-
});
|
|
3175
|
-
assertH5OperationOk(result, 'flutter_h5_scroll_failed');
|
|
3176
|
-
return result;
|
|
3177
|
-
}
|
|
3178
|
-
|
|
3179
|
-
async function flutterH5Wait(ctx, options) {
|
|
3180
|
-
const timeoutSec = Number(options.timeoutSec || 10);
|
|
3181
|
-
const intervalMs = Number(options.intervalMs || 500);
|
|
3182
|
-
const deadline = Date.now() + timeoutSec * 1000;
|
|
3183
|
-
let lastResult = null;
|
|
3184
|
-
while (Date.now() <= deadline) {
|
|
3185
|
-
lastResult = await flutterH5Operation(ctx, 'find', h5TargetOptions(options));
|
|
3186
|
-
if (lastResult.result?.ok) {
|
|
3187
|
-
return { ...lastResult, timeoutSec, intervalMs };
|
|
3188
|
-
}
|
|
3189
|
-
await sleep(intervalMs);
|
|
3190
|
-
}
|
|
3191
|
-
return {
|
|
3192
|
-
ok: false,
|
|
3193
|
-
error: 'flutter_h5_target_not_found',
|
|
3194
|
-
timeoutSec,
|
|
3195
|
-
intervalMs,
|
|
3196
|
-
lastResult,
|
|
3197
|
-
};
|
|
3198
|
-
}
|
|
3199
|
-
|
|
3200
|
-
async function flutterH5Operation(ctx, action, params) {
|
|
3201
|
-
const response = await flutterH5Eval(ctx, {
|
|
3202
|
-
script: h5OperationScript(action, params),
|
|
3203
|
-
});
|
|
3204
|
-
return {
|
|
3205
|
-
...response,
|
|
3206
|
-
source: 'flutter-h5-adapter',
|
|
3207
|
-
action,
|
|
3208
|
-
request: params,
|
|
3209
|
-
result: normalizeH5EvalResult(response.result),
|
|
3210
|
-
};
|
|
3211
|
-
}
|
|
3212
|
-
|
|
3213
|
-
function h5TargetOptions(options) {
|
|
3214
|
-
return {
|
|
3215
|
-
selector: options.selector || '',
|
|
3216
|
-
targetText: options.targetText || options.textContains || '',
|
|
3217
|
-
exact: booleanOption(options.exact),
|
|
3218
|
-
};
|
|
3219
|
-
}
|
|
3220
|
-
|
|
3221
|
-
function booleanOption(value) {
|
|
3222
|
-
if (typeof value === 'boolean') return value;
|
|
3223
|
-
return ['1', 'true', 'yes', 'on'].includes(String(value || '').toLowerCase());
|
|
3224
|
-
}
|
|
3225
|
-
|
|
3226
|
-
function assertH5OperationOk(response, errorName) {
|
|
3227
|
-
if (!response.ok) {
|
|
3228
|
-
throw new Error(`${errorName}: ${response.error || 'bridge_error'}`);
|
|
3229
|
-
}
|
|
3230
|
-
if (!response.result?.ok) {
|
|
3231
|
-
throw new Error(`${errorName}: ${response.result?.error || 'target_not_found'}`);
|
|
3232
|
-
}
|
|
3233
|
-
}
|
|
3234
|
-
|
|
3235
|
-
function normalizeH5EvalResult(value) {
|
|
3236
|
-
if (typeof value === 'string') {
|
|
3237
|
-
try {
|
|
3238
|
-
return JSON.parse(value);
|
|
3239
|
-
} catch (_) {
|
|
3240
|
-
return { ok: true, value };
|
|
3241
|
-
}
|
|
3242
|
-
}
|
|
3243
|
-
return value || null;
|
|
3244
|
-
}
|
|
3245
|
-
|
|
3246
|
-
function h5OperationScript(action, params) {
|
|
3247
|
-
const payload = JSON.stringify({ action, ...params });
|
|
3248
|
-
return `
|
|
3249
|
-
(function() {
|
|
3250
|
-
var params = ${payload};
|
|
3251
|
-
function text(value) {
|
|
3252
|
-
return value == null ? '' : String(value);
|
|
3253
|
-
}
|
|
3254
|
-
function cut(value, max) {
|
|
3255
|
-
var raw = text(value);
|
|
3256
|
-
return raw.length > max ? raw.slice(0, max) : raw;
|
|
3257
|
-
}
|
|
3258
|
-
function visible(element) {
|
|
3259
|
-
if (!element) return false;
|
|
3260
|
-
var style = window.getComputedStyle ? window.getComputedStyle(element) : null;
|
|
3261
|
-
if (style && (style.display === 'none' || style.visibility === 'hidden')) return false;
|
|
3262
|
-
var rect = element.getBoundingClientRect();
|
|
3263
|
-
return rect.width > 0 && rect.height > 0;
|
|
3264
|
-
}
|
|
3265
|
-
function bounds(element) {
|
|
3266
|
-
var rect = element.getBoundingClientRect();
|
|
3267
|
-
return {
|
|
3268
|
-
left: rect.left,
|
|
3269
|
-
top: rect.top,
|
|
3270
|
-
right: rect.right,
|
|
3271
|
-
bottom: rect.bottom,
|
|
3272
|
-
width: rect.width,
|
|
3273
|
-
height: rect.height
|
|
3274
|
-
};
|
|
3275
|
-
}
|
|
3276
|
-
function label(element) {
|
|
3277
|
-
return [
|
|
3278
|
-
element.innerText,
|
|
3279
|
-
element.value,
|
|
3280
|
-
element.title,
|
|
3281
|
-
element.id,
|
|
3282
|
-
element.name,
|
|
3283
|
-
element.getAttribute('aria-label'),
|
|
3284
|
-
element.getAttribute('placeholder'),
|
|
3285
|
-
element.getAttribute('role')
|
|
3286
|
-
].map(text).filter(Boolean).join('\\n');
|
|
3287
|
-
}
|
|
3288
|
-
function matchesText(element, targetText) {
|
|
3289
|
-
if (!targetText) return true;
|
|
3290
|
-
var source = label(element);
|
|
3291
|
-
return params.exact ? source === targetText : source.indexOf(targetText) >= 0;
|
|
3292
|
-
}
|
|
3293
|
-
function describe(element) {
|
|
3294
|
-
if (!element) return null;
|
|
3295
|
-
return {
|
|
3296
|
-
tag: text(element.tagName).toLowerCase(),
|
|
3297
|
-
id: text(element.id),
|
|
3298
|
-
name: text(element.getAttribute('name')),
|
|
3299
|
-
type: text(element.getAttribute('type')),
|
|
3300
|
-
role: text(element.getAttribute('role')),
|
|
3301
|
-
ariaLabel: text(element.getAttribute('aria-label')),
|
|
3302
|
-
placeholder: text(element.getAttribute('placeholder')),
|
|
3303
|
-
text: cut(element.innerText || element.value || element.title || element.getAttribute('aria-label'), 500),
|
|
3304
|
-
value: cut(element.value, 500),
|
|
3305
|
-
disabled: !!element.disabled,
|
|
3306
|
-
bounds: bounds(element)
|
|
3307
|
-
};
|
|
3308
|
-
}
|
|
3309
|
-
function findElement() {
|
|
3310
|
-
var selector = text(params.selector);
|
|
3311
|
-
var targetText = text(params.targetText);
|
|
3312
|
-
var candidates = [];
|
|
3313
|
-
if (selector) {
|
|
3314
|
-
candidates = Array.prototype.slice.call(document.querySelectorAll(selector), 0, 200);
|
|
3315
|
-
} else if (targetText) {
|
|
3316
|
-
candidates = Array.prototype.slice.call(document.querySelectorAll('a,button,input,textarea,select,[role],[onclick],[aria-label],[contenteditable="true"]'), 0, 500);
|
|
3317
|
-
if (!candidates.length) {
|
|
3318
|
-
candidates = Array.prototype.slice.call(document.body ? document.body.querySelectorAll('*') : [], 0, 1000);
|
|
3319
|
-
}
|
|
3320
|
-
} else if (document.activeElement) {
|
|
3321
|
-
candidates = [document.activeElement];
|
|
3322
|
-
}
|
|
3323
|
-
var matched = candidates.filter(function(element) {
|
|
3324
|
-
return matchesText(element, targetText);
|
|
3325
|
-
});
|
|
3326
|
-
return matched.find(visible) || matched[0] || null;
|
|
3327
|
-
}
|
|
3328
|
-
function dispatch(element, name) {
|
|
3329
|
-
var event = new Event(name, { bubbles: true, cancelable: true });
|
|
3330
|
-
element.dispatchEvent(event);
|
|
3331
|
-
}
|
|
3332
|
-
function pointer(element, name) {
|
|
3333
|
-
try {
|
|
3334
|
-
element.dispatchEvent(new MouseEvent(name, { bubbles: true, cancelable: true, view: window }));
|
|
3335
|
-
} catch (_) {
|
|
3336
|
-
dispatch(element, name);
|
|
3337
|
-
}
|
|
3338
|
-
}
|
|
3339
|
-
function bodyText() {
|
|
3340
|
-
return cut(document.body && document.body.innerText, 2000);
|
|
3341
|
-
}
|
|
3342
|
-
|
|
3343
|
-
var targetText = text(params.targetText);
|
|
3344
|
-
if (params.action === 'find' && targetText && bodyText().indexOf(targetText) >= 0) {
|
|
3345
|
-
return JSON.stringify({
|
|
3346
|
-
ok: true,
|
|
3347
|
-
action: params.action,
|
|
3348
|
-
matchSource: 'bodyText',
|
|
3349
|
-
bodyText: bodyText(),
|
|
3350
|
-
updatedAtMs: Date.now()
|
|
3351
|
-
});
|
|
3352
|
-
}
|
|
3353
|
-
|
|
3354
|
-
var element = findElement();
|
|
3355
|
-
if (!element) {
|
|
3356
|
-
return JSON.stringify({
|
|
3357
|
-
ok: false,
|
|
3358
|
-
action: params.action,
|
|
3359
|
-
error: 'target_not_found',
|
|
3360
|
-
selector: text(params.selector),
|
|
3361
|
-
targetText: targetText,
|
|
3362
|
-
bodyText: bodyText(),
|
|
3363
|
-
updatedAtMs: Date.now()
|
|
3364
|
-
});
|
|
3365
|
-
}
|
|
3366
|
-
|
|
3367
|
-
if (params.action === 'click') {
|
|
3368
|
-
element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
|
3369
|
-
if (element.focus) element.focus();
|
|
3370
|
-
pointer(element, 'mousedown');
|
|
3371
|
-
pointer(element, 'mouseup');
|
|
3372
|
-
if (element.click) {
|
|
3373
|
-
element.click();
|
|
3374
|
-
} else {
|
|
3375
|
-
pointer(element, 'click');
|
|
3376
|
-
}
|
|
3377
|
-
} else if (params.action === 'input') {
|
|
3378
|
-
element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
|
3379
|
-
if (element.focus) element.focus();
|
|
3380
|
-
if ('value' in element) {
|
|
3381
|
-
element.value = text(params.value);
|
|
3382
|
-
} else if (element.isContentEditable) {
|
|
3383
|
-
element.innerText = text(params.value);
|
|
3384
|
-
} else {
|
|
3385
|
-
return JSON.stringify({
|
|
3386
|
-
ok: false,
|
|
3387
|
-
action: params.action,
|
|
3388
|
-
error: 'target_not_editable',
|
|
3389
|
-
matched: describe(element),
|
|
3390
|
-
updatedAtMs: Date.now()
|
|
3391
|
-
});
|
|
3392
|
-
}
|
|
3393
|
-
dispatch(element, 'input');
|
|
3394
|
-
dispatch(element, 'change');
|
|
3395
|
-
} else if (params.action === 'scroll') {
|
|
3396
|
-
if (params.selector || targetText) {
|
|
3397
|
-
element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
|
3398
|
-
} else {
|
|
3399
|
-
window.scrollBy(Number(params.deltaX || 0), Number(params.deltaY || 0));
|
|
3400
|
-
}
|
|
3401
|
-
}
|
|
3402
|
-
|
|
3403
|
-
return JSON.stringify({
|
|
3404
|
-
ok: true,
|
|
3405
|
-
action: params.action,
|
|
3406
|
-
matched: describe(element),
|
|
3407
|
-
value: cut(element.value, 500),
|
|
3408
|
-
bodyText: bodyText(),
|
|
3409
|
-
scroll: {
|
|
3410
|
-
x: window.scrollX,
|
|
3411
|
-
y: window.scrollY
|
|
3412
|
-
},
|
|
3413
|
-
updatedAtMs: Date.now()
|
|
3414
|
-
});
|
|
3415
|
-
})()
|
|
3416
|
-
`;
|
|
3417
|
-
}
|
|
3418
|
-
|
|
3419
|
-
async function textPresent(ctx, targetText) {
|
|
3420
|
-
return waitTextConditionsMet(await textSnapshot(ctx), targetText).ok;
|
|
3421
|
-
}
|
|
3422
|
-
|
|
3423
|
-
async function textSnapshot(ctx) {
|
|
3424
|
-
const parts = [];
|
|
3425
|
-
let activity = '';
|
|
3426
|
-
for (const loader of [
|
|
3427
|
-
async () => {
|
|
3428
|
-
const status = await bridgeGet(ctx, '/v1/status');
|
|
3429
|
-
activity = String(status?.activity?.current || '');
|
|
3430
|
-
return statusSearchText(status);
|
|
3431
|
-
},
|
|
3432
|
-
async () => JSON.stringify(await bridgeGet(ctx, '/v1/view/tree')),
|
|
3433
|
-
async () => await uiaTree(ctx),
|
|
3434
|
-
]) {
|
|
3435
|
-
try {
|
|
3436
|
-
const text = await loader();
|
|
3437
|
-
parts.push(text);
|
|
3438
|
-
} catch (_) {}
|
|
3439
|
-
}
|
|
3440
|
-
return {
|
|
3441
|
-
text: parts.join('\n'),
|
|
3442
|
-
activity,
|
|
3443
|
-
};
|
|
3444
|
-
}
|
|
3445
|
-
|
|
3446
|
-
function statusSearchText(status) {
|
|
3447
|
-
if (!status || typeof status !== 'object') return '';
|
|
3448
|
-
const parts = [];
|
|
3449
|
-
parts.push(JSON.stringify({
|
|
3450
|
-
debugBridge: status.debugBridge,
|
|
3451
|
-
app: status.app,
|
|
3452
|
-
android: status.android,
|
|
3453
|
-
activity: status.activity,
|
|
3454
|
-
capture: status.capture,
|
|
3455
|
-
}));
|
|
3456
|
-
const flutter = status.flutter;
|
|
3457
|
-
if (flutter && typeof flutter === 'object') {
|
|
3458
|
-
parts.push(JSON.stringify({
|
|
3459
|
-
app: flutter.app,
|
|
3460
|
-
route: flutter.route,
|
|
3461
|
-
h5: flutter.h5 ? flutterH5SearchObject(flutter.h5) : undefined,
|
|
3462
|
-
}));
|
|
3463
|
-
const nodes = Array.isArray(flutter.layout?.operable?.nodes) ? flutter.layout.operable.nodes : [];
|
|
3464
|
-
for (const node of nodes) {
|
|
3465
|
-
parts.push(JSON.stringify({
|
|
3466
|
-
widgetType: node.widgetType,
|
|
3467
|
-
text: node.text,
|
|
3468
|
-
value: node.value,
|
|
3469
|
-
actions: node.actions,
|
|
3470
|
-
}));
|
|
3471
|
-
}
|
|
3472
|
-
}
|
|
3473
|
-
return parts.join('\n');
|
|
3474
|
-
}
|
|
3475
|
-
|
|
3476
|
-
function flutterH5SearchObject(h5) {
|
|
3477
|
-
if (!h5 || typeof h5 !== 'object') return h5;
|
|
3478
|
-
return {
|
|
3479
|
-
active: h5.active,
|
|
3480
|
-
adapterId: h5.adapterId,
|
|
3481
|
-
source: h5.source,
|
|
3482
|
-
currentUrl: h5.currentUrl,
|
|
3483
|
-
progress: h5.progress,
|
|
3484
|
-
title: h5.title,
|
|
3485
|
-
dom: h5.dom ? {
|
|
3486
|
-
ok: h5.dom.ok,
|
|
3487
|
-
title: h5.dom.title,
|
|
3488
|
-
url: h5.dom.url,
|
|
3489
|
-
readyState: h5.dom.readyState,
|
|
3490
|
-
bodyText: h5.dom.bodyText,
|
|
3491
|
-
controls: Array.isArray(h5.dom.controls)
|
|
3492
|
-
? h5.dom.controls.map((control) => ({
|
|
3493
|
-
tag: control.tag,
|
|
3494
|
-
id: control.id,
|
|
3495
|
-
name: control.name,
|
|
3496
|
-
type: control.type,
|
|
3497
|
-
role: control.role,
|
|
3498
|
-
ariaLabel: control.ariaLabel,
|
|
3499
|
-
placeholder: control.placeholder,
|
|
3500
|
-
text: control.text,
|
|
3501
|
-
href: control.href,
|
|
3502
|
-
disabled: control.disabled,
|
|
3503
|
-
}))
|
|
3504
|
-
: h5.dom.controls,
|
|
3505
|
-
controlCount: h5.dom.controlCount,
|
|
3506
|
-
} : undefined,
|
|
3507
|
-
};
|
|
3508
|
-
}
|
|
3509
|
-
|
|
3510
|
-
function waitTextConditionsMet(snapshot, targetText, options = {}) {
|
|
3511
|
-
const text = String(snapshot?.text || '');
|
|
3512
|
-
const activity = String(snapshot?.activity || '');
|
|
3513
|
-
if (!text.includes(targetText)) {
|
|
3514
|
-
return { ok: false, reason: 'target_text_missing', targetText, activity };
|
|
3515
|
-
}
|
|
3516
|
-
const requireActivity = String(options.requireActivity || '');
|
|
3517
|
-
if (requireActivity && !activity.includes(requireActivity)) {
|
|
3518
|
-
return {
|
|
3519
|
-
ok: false,
|
|
3520
|
-
reason: 'activity_mismatch',
|
|
3521
|
-
targetText,
|
|
3522
|
-
activity,
|
|
3523
|
-
requireActivity,
|
|
3524
|
-
};
|
|
3525
|
-
}
|
|
3526
|
-
const missingTexts = (options.requireTexts || []).filter((value) => !text.includes(value));
|
|
3527
|
-
if (missingTexts.length) {
|
|
3528
|
-
return {
|
|
3529
|
-
ok: false,
|
|
3530
|
-
reason: 'required_text_missing',
|
|
3531
|
-
targetText,
|
|
3532
|
-
activity,
|
|
3533
|
-
missingTexts,
|
|
3534
|
-
};
|
|
3535
|
-
}
|
|
3536
|
-
const presentAbsentTexts = (options.absentTexts || []).filter((value) => text.includes(value));
|
|
3537
|
-
if (presentAbsentTexts.length) {
|
|
3538
|
-
return {
|
|
3539
|
-
ok: false,
|
|
3540
|
-
reason: 'absent_text_present',
|
|
3541
|
-
targetText,
|
|
3542
|
-
activity,
|
|
3543
|
-
presentAbsentTexts,
|
|
3544
|
-
};
|
|
3545
|
-
}
|
|
3546
|
-
return { ok: true, targetText, activity };
|
|
3547
|
-
}
|
|
3548
|
-
|
|
3549
|
-
async function keyboardState(ctx) {
|
|
3550
|
-
try {
|
|
3551
|
-
const result = await adb(ctx, ['shell', 'dumpsys', 'input_method']);
|
|
3552
|
-
return parseKeyboardState(result.stdout);
|
|
3553
|
-
} catch (error) {
|
|
3554
|
-
return {
|
|
3555
|
-
ok: false,
|
|
3556
|
-
error: 'keyboard_state_probe_failed',
|
|
3557
|
-
message: firstErrorLine(error),
|
|
3558
|
-
};
|
|
3559
|
-
}
|
|
3560
|
-
}
|
|
3561
|
-
|
|
3562
|
-
function parseKeyboardState(raw) {
|
|
3563
|
-
const text = String(raw || '');
|
|
3564
|
-
const inputShown = text.includes('mInputShown=true') || text.includes('inputShown=true');
|
|
3565
|
-
const windowVisible = text.includes('mWindowVisible=true');
|
|
3566
|
-
const inputViewShown = text.includes('mIsInputViewShown=true') || text.includes('mInputViewStarted=true');
|
|
3567
|
-
const imeWindowVisible = /\bmImeWindowVis=0x[13]\b/i.test(text) || /\bmImeWindowVisibility=0x[13]\b/i.test(text);
|
|
3568
|
-
const markers = [];
|
|
3569
|
-
if (inputShown) markers.push('mInputShown=true');
|
|
3570
|
-
if (windowVisible) markers.push('mWindowVisible=true');
|
|
3571
|
-
if (inputViewShown) markers.push('mIsInputViewShown=true');
|
|
3572
|
-
if (imeWindowVisible) markers.push('mImeWindowVis');
|
|
3573
|
-
const hiddenMarkers = [
|
|
3574
|
-
'mInputShown=false',
|
|
3575
|
-
'mWindowVisible=false',
|
|
3576
|
-
'mImeWindowVis=0',
|
|
3577
|
-
].filter((marker) => text.includes(marker));
|
|
3578
|
-
return {
|
|
3579
|
-
ok: true,
|
|
3580
|
-
source: 'dumpsys input_method',
|
|
3581
|
-
visible: inputShown || imeWindowVisible || (windowVisible && inputViewShown),
|
|
3582
|
-
markers,
|
|
3583
|
-
hiddenMarkers,
|
|
3584
|
-
};
|
|
3585
|
-
}
|
|
3586
|
-
|
|
3587
|
-
async function hideKeyboard(ctx, options = {}) {
|
|
3588
|
-
const before = await keyboardState(ctx);
|
|
3589
|
-
const force = booleanOption(options.force);
|
|
3590
|
-
if (!force && before.ok && !before.visible) {
|
|
3591
|
-
return {
|
|
3592
|
-
ok: true,
|
|
3593
|
-
action: 'hide-keyboard',
|
|
3594
|
-
dismissed: false,
|
|
3595
|
-
reason: 'keyboard_not_visible',
|
|
3596
|
-
before,
|
|
3597
|
-
after: before,
|
|
3598
|
-
attempts: [],
|
|
3599
|
-
};
|
|
3600
|
-
}
|
|
3601
|
-
|
|
3602
|
-
const attempts = [];
|
|
3603
|
-
for (const keyCode of [111, 4]) {
|
|
3604
|
-
await keyevent(ctx, keyCode);
|
|
3605
|
-
await sleep(Number(options.intervalMs || 500));
|
|
3606
|
-
const after = await keyboardState(ctx);
|
|
3607
|
-
attempts.push({ keyCode, visible: after.visible, ok: after.ok });
|
|
3608
|
-
if (after.ok && !after.visible) {
|
|
3609
|
-
return {
|
|
3610
|
-
ok: true,
|
|
3611
|
-
action: 'hide-keyboard',
|
|
3612
|
-
dismissed: true,
|
|
3613
|
-
before,
|
|
3614
|
-
after,
|
|
3615
|
-
attempts,
|
|
3616
|
-
};
|
|
3617
|
-
}
|
|
3618
|
-
}
|
|
3619
|
-
|
|
3620
|
-
const after = await keyboardState(ctx);
|
|
3621
|
-
return {
|
|
3622
|
-
ok: false,
|
|
3623
|
-
action: 'hide-keyboard',
|
|
3624
|
-
error: 'keyboard_still_visible',
|
|
3625
|
-
dismissed: false,
|
|
3626
|
-
before,
|
|
3627
|
-
after,
|
|
3628
|
-
attempts,
|
|
3629
|
-
};
|
|
3630
|
-
}
|
|
3631
|
-
|
|
3632
|
-
async function maybeHideKeyboardForPoint(ctx, point, viewport) {
|
|
3633
|
-
const state = await keyboardState(ctx);
|
|
3634
|
-
const decision = shouldDismissKeyboardForPoint({ point, viewport, keyboardVisible: state.visible });
|
|
3635
|
-
if (!decision.dismiss) {
|
|
3636
|
-
return {
|
|
3637
|
-
dismissed: false,
|
|
3638
|
-
state,
|
|
3639
|
-
decision,
|
|
3640
|
-
};
|
|
3641
|
-
}
|
|
3642
|
-
const hide = await hideKeyboard(ctx, { reason: decision.reason });
|
|
3643
|
-
return {
|
|
3644
|
-
dismissed: hide.dismissed,
|
|
3645
|
-
state,
|
|
3646
|
-
decision,
|
|
3647
|
-
hide,
|
|
3648
|
-
};
|
|
3649
|
-
}
|
|
3650
|
-
|
|
3651
|
-
function shouldDismissKeyboardForPoint({ point, viewport, keyboardVisible }) {
|
|
3652
|
-
if (!keyboardVisible) {
|
|
3653
|
-
return { dismiss: false, reason: 'keyboard_not_visible' };
|
|
3654
|
-
}
|
|
3655
|
-
if (!point || !viewport) {
|
|
3656
|
-
return { dismiss: false, reason: 'missing_geometry' };
|
|
3657
|
-
}
|
|
3658
|
-
const top = Number(viewport.top || 0);
|
|
3659
|
-
const bottom = Number(viewport.bottom);
|
|
3660
|
-
if (!Number.isFinite(bottom) || bottom <= top) {
|
|
3661
|
-
return { dismiss: false, reason: 'invalid_viewport' };
|
|
3662
|
-
}
|
|
3663
|
-
const threshold = top + (bottom - top) * 0.58;
|
|
3664
|
-
if (Number(point.y) >= threshold) {
|
|
3665
|
-
return {
|
|
3666
|
-
dismiss: true,
|
|
3667
|
-
reason: 'target_may_be_obscured_by_keyboard',
|
|
3668
|
-
threshold,
|
|
3669
|
-
};
|
|
3670
|
-
}
|
|
3671
|
-
return {
|
|
3672
|
-
dismiss: false,
|
|
3673
|
-
reason: 'target_above_keyboard_risk_area',
|
|
3674
|
-
threshold,
|
|
3675
|
-
};
|
|
3676
|
-
}
|
|
3677
|
-
|
|
3678
|
-
async function inputText(ctx, text, options = {}) {
|
|
3679
|
-
const bridgePayload = inputTextBridgePayload(text, options);
|
|
3680
|
-
let bridgeAttempt = null;
|
|
3681
|
-
try {
|
|
3682
|
-
const bridgeResult = await bridgePost(ctx, '/v1/action/input-text', bridgePayload);
|
|
3683
|
-
if (bridgeResult?.ok) {
|
|
3684
|
-
const result = {
|
|
3685
|
-
...bridgeResult,
|
|
3686
|
-
transport: 'bridge',
|
|
3687
|
-
source: bridgeResult.source || 'native-view',
|
|
3688
|
-
request: bridgePayload,
|
|
3689
|
-
};
|
|
3690
|
-
if (booleanOption(options.hideKeyboard)) {
|
|
3691
|
-
result.keyboard = await hideKeyboard(ctx, options);
|
|
3692
|
-
}
|
|
3693
|
-
return result;
|
|
3694
|
-
}
|
|
3695
|
-
bridgeAttempt = {
|
|
3696
|
-
ok: false,
|
|
3697
|
-
command: 'input-text',
|
|
3698
|
-
requestPath: '/v1/action/input-text',
|
|
3699
|
-
result: bridgeResult,
|
|
3700
|
-
error: bridgeResult?.error || 'bridge_input_failed',
|
|
3701
|
-
message: bridgeResult?.message || 'The app bridge did not accept native text input.',
|
|
3702
|
-
};
|
|
3703
|
-
} catch (error) {
|
|
3704
|
-
bridgeAttempt = buildBridgeFailureResult(ctx, 'input-text', '/v1/action/input-text', error);
|
|
3705
|
-
}
|
|
3706
|
-
|
|
3707
|
-
if (!isAdbInputTextSafe(text)) {
|
|
3708
|
-
return {
|
|
3709
|
-
ok: false,
|
|
3710
|
-
error: 'unicode_text_requires_bridge_input',
|
|
3711
|
-
message: 'This text contains non-ASCII characters. Android adb shell input text cannot reliably enter Unicode; use an app build with AI App Bridge Android runtime 0.1.9+ and retry input-text/input_text.',
|
|
3712
|
-
textLength: text.length,
|
|
3713
|
-
bridge: bridgeAttempt,
|
|
3714
|
-
suggestion: 'Update the target app bridge dependency to ai-app-bridge-android 0.1.9+ and pass --package-name so the CLI can discover the app bridge port.',
|
|
3715
|
-
};
|
|
3716
|
-
}
|
|
3717
|
-
|
|
3718
|
-
await adb(ctx, ['shell', 'input', 'text', text.replace(/ /g, '%s')]);
|
|
3719
|
-
const result = {
|
|
3720
|
-
ok: true,
|
|
3721
|
-
transport: 'adb',
|
|
3722
|
-
source: 'adb-input-text-fallback',
|
|
3723
|
-
text,
|
|
3724
|
-
bridge: bridgeAttempt,
|
|
3725
|
-
};
|
|
3726
|
-
if (booleanOption(options.hideKeyboard)) {
|
|
3727
|
-
result.keyboard = await hideKeyboard(ctx, options);
|
|
3728
|
-
}
|
|
3729
|
-
return result;
|
|
3730
|
-
}
|
|
3731
|
-
|
|
3732
|
-
function inputTextBridgePayload(text, options = {}) {
|
|
3733
|
-
const payload = { text };
|
|
3734
|
-
const x = Number(options.tapX);
|
|
3735
|
-
const y = Number(options.tapY);
|
|
3736
|
-
if (Number.isFinite(x) && Number.isFinite(y)) {
|
|
3737
|
-
payload.x = x;
|
|
3738
|
-
payload.y = y;
|
|
3739
|
-
}
|
|
3740
|
-
return payload;
|
|
3741
|
-
}
|
|
3742
|
-
|
|
3743
|
-
function isAdbInputTextSafe(text) {
|
|
3744
|
-
return /^[\x20-\x7e]*$/.test(String(text));
|
|
3745
|
-
}
|
|
3746
|
-
|
|
3747
|
-
async function swipe(ctx, startX, startY, endX, endY, durationMs) {
|
|
3748
|
-
await adb(ctx, ['shell', 'input', 'swipe', String(startX), String(startY), String(endX), String(endY), String(durationMs)]);
|
|
3749
|
-
return { ok: true, transport: 'adb', startX, startY, endX, endY, durationMs };
|
|
3750
|
-
}
|
|
3751
|
-
|
|
3752
|
-
async function keyevent(ctx, keyCode) {
|
|
3753
|
-
await adb(ctx, ['shell', 'input', 'keyevent', String(keyCode)]);
|
|
3754
|
-
return { ok: true, transport: 'adb', keyCode };
|
|
3755
|
-
}
|
|
3756
|
-
|
|
3757
|
-
async function logcat(ctx, options) {
|
|
3758
|
-
const follow = Boolean(options.follow || options.live);
|
|
3759
|
-
const args = ['logcat', '-v', String(options.logcatFormat || options.format || 'threadtime')];
|
|
3760
|
-
if (options.clear || options.clearFirst) {
|
|
3761
|
-
await adb(ctx, ['logcat', '-c']);
|
|
3762
|
-
}
|
|
3763
|
-
if (!follow) {
|
|
3764
|
-
args.push('-d');
|
|
3765
|
-
}
|
|
3766
|
-
const since = options.logcatSince || options.since;
|
|
3767
|
-
if (since) {
|
|
3768
|
-
args.push('-T', String(since));
|
|
3769
|
-
} else if (!follow) {
|
|
3770
|
-
args.push('-t', String(options.logcatLines || options.lines || 200));
|
|
3771
|
-
}
|
|
3772
|
-
if (options.logcatFilter) {
|
|
3773
|
-
args.push(...String(options.logcatFilter).split(',').map((value) => value.trim()).filter(Boolean));
|
|
3774
|
-
}
|
|
3775
|
-
const text = follow
|
|
3776
|
-
? await adbFollow(ctx, args, Number(options.durationMs || Number(options.durationSec || 5) * 1000))
|
|
3777
|
-
: (await adb(ctx, args)).stdout;
|
|
3778
|
-
const pid = await resolveLogcatPid(ctx, options);
|
|
3779
|
-
return filterLogcat(text, { ...options, pid });
|
|
3780
|
-
}
|
|
3781
|
-
|
|
3782
|
-
async function permissionState(ctx, permission) {
|
|
3783
|
-
const result = await adb(ctx, ['shell', 'dumpsys', 'package', ctx.packageName]);
|
|
3784
|
-
const pattern = new RegExp(`${escapeRegExp(permission)}:\\s+granted=(true|false)(?:,\\s*flags=\\[([^\\]]*)\\])?`);
|
|
3785
|
-
const match = pattern.exec(result.stdout);
|
|
3786
|
-
if (!match) {
|
|
3787
|
-
return {
|
|
3788
|
-
ok: false,
|
|
3789
|
-
packageName: ctx.packageName,
|
|
3790
|
-
permission,
|
|
3791
|
-
error: 'permission_not_found_in_dumpsys',
|
|
3792
|
-
};
|
|
3793
|
-
}
|
|
3794
|
-
return {
|
|
3795
|
-
ok: true,
|
|
3796
|
-
packageName: ctx.packageName,
|
|
3797
|
-
permission,
|
|
3798
|
-
granted: match[1] === 'true',
|
|
3799
|
-
flags: splitCsv(match[2] || ''),
|
|
3800
|
-
};
|
|
3801
|
-
}
|
|
3802
|
-
|
|
3803
|
-
async function permissionGrant(ctx, permission) {
|
|
3804
|
-
try {
|
|
3805
|
-
await adb(ctx, ['shell', 'pm', 'grant', ctx.packageName, permission]);
|
|
3806
|
-
return {
|
|
3807
|
-
action: 'grant',
|
|
3808
|
-
...(await permissionState(ctx, permission)),
|
|
3809
|
-
};
|
|
3810
|
-
} catch (error) {
|
|
3811
|
-
return {
|
|
3812
|
-
ok: false,
|
|
3813
|
-
action: 'grant',
|
|
3814
|
-
packageName: ctx.packageName,
|
|
3815
|
-
permission,
|
|
3816
|
-
error: error.message || String(error),
|
|
3817
|
-
state: await safePermissionState(ctx, permission),
|
|
3818
|
-
};
|
|
3819
|
-
}
|
|
3820
|
-
}
|
|
3821
|
-
|
|
3822
|
-
async function permissionRevoke(ctx, permission) {
|
|
3823
|
-
try {
|
|
3824
|
-
await adb(ctx, ['shell', 'pm', 'revoke', ctx.packageName, permission]);
|
|
3825
|
-
return {
|
|
3826
|
-
action: 'revoke',
|
|
3827
|
-
...(await permissionState(ctx, permission)),
|
|
3828
|
-
};
|
|
3829
|
-
} catch (error) {
|
|
3830
|
-
return {
|
|
3831
|
-
ok: false,
|
|
3832
|
-
action: 'revoke',
|
|
3833
|
-
packageName: ctx.packageName,
|
|
3834
|
-
permission,
|
|
3835
|
-
error: error.message || String(error),
|
|
3836
|
-
state: await safePermissionState(ctx, permission),
|
|
3837
|
-
};
|
|
3838
|
-
}
|
|
3839
|
-
}
|
|
3840
|
-
|
|
3841
|
-
async function safePermissionState(ctx, permission) {
|
|
3842
|
-
try {
|
|
3843
|
-
return await permissionState(ctx, permission);
|
|
3844
|
-
} catch (error) {
|
|
3845
|
-
return { ok: false, error: error.message || String(error) };
|
|
3846
|
-
}
|
|
3847
|
-
}
|
|
3848
|
-
|
|
3849
|
-
async function appopsSet(ctx, op, mode) {
|
|
3850
|
-
await adb(ctx, ['shell', 'appops', 'set', ctx.packageName, op, mode]);
|
|
3851
|
-
return {
|
|
3852
|
-
ok: true,
|
|
3853
|
-
packageName: ctx.packageName,
|
|
3854
|
-
op,
|
|
3855
|
-
mode,
|
|
3856
|
-
};
|
|
3857
|
-
}
|
|
3858
|
-
|
|
3859
|
-
async function clearAppData(ctx) {
|
|
3860
|
-
if (!ctx.explicitPackageName) {
|
|
3861
|
-
throw new Error('packageName is required for clear-app-data');
|
|
3862
|
-
}
|
|
3863
|
-
let bridgeError = null;
|
|
3864
|
-
try {
|
|
3865
|
-
const bridgeResult = await bridgePost(ctx, '/v1/app/clear-data', {});
|
|
3866
|
-
return {
|
|
3867
|
-
...bridgeResult,
|
|
3868
|
-
packageName: ctx.packageName,
|
|
3869
|
-
method: 'bridge-runtime',
|
|
3870
|
-
};
|
|
3871
|
-
} catch (error) {
|
|
3872
|
-
bridgeError = firstErrorLine(error);
|
|
3873
|
-
}
|
|
3874
|
-
|
|
3875
|
-
let result;
|
|
3876
|
-
try {
|
|
3877
|
-
result = await adb(ctx, clearAppDataAdbArgs(ctx.packageName));
|
|
3878
|
-
} catch (error) {
|
|
3879
|
-
return {
|
|
3880
|
-
ok: false,
|
|
3881
|
-
packageName: ctx.packageName,
|
|
3882
|
-
action: 'clear-app-data',
|
|
3883
|
-
error: 'clear_app_data_failed',
|
|
3884
|
-
bridgeError,
|
|
3885
|
-
message: error.message || String(error),
|
|
3886
|
-
};
|
|
3887
|
-
}
|
|
3888
|
-
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
|
3889
|
-
if (!/^Success\b/im.test(output)) {
|
|
3890
|
-
return {
|
|
3891
|
-
ok: false,
|
|
3892
|
-
packageName: ctx.packageName,
|
|
3893
|
-
error: 'clear_app_data_failed',
|
|
3894
|
-
bridgeError,
|
|
3895
|
-
output,
|
|
3896
|
-
};
|
|
3897
|
-
}
|
|
3898
|
-
return {
|
|
3899
|
-
ok: true,
|
|
3900
|
-
packageName: ctx.packageName,
|
|
3901
|
-
action: 'clear-app-data',
|
|
3902
|
-
method: 'pm-clear',
|
|
3903
|
-
bridgeError,
|
|
3904
|
-
output,
|
|
3905
|
-
};
|
|
3906
|
-
}
|
|
3907
|
-
|
|
3908
|
-
function clearAppDataAdbArgs(packageName) {
|
|
3909
|
-
return ['shell', 'pm', 'clear', packageName];
|
|
3910
|
-
}
|
|
3911
|
-
|
|
3912
|
-
async function resolveLogcatPid(ctx, options) {
|
|
3913
|
-
if (options.pid && options.pid !== true && options.pid !== 'current') {
|
|
3914
|
-
return String(options.pid);
|
|
3915
|
-
}
|
|
3916
|
-
if (options.pid === 'current' || options.appPid || options.packagePid) {
|
|
3917
|
-
try {
|
|
3918
|
-
const result = await adb(ctx, ['shell', 'pidof', '-s', ctx.packageName]);
|
|
3919
|
-
return result.stdout.trim().split(/\s+/).filter(Boolean)[0] || '';
|
|
3920
|
-
} catch (_) {
|
|
3921
|
-
return '';
|
|
3922
|
-
}
|
|
3923
|
-
}
|
|
3924
|
-
return '';
|
|
3925
|
-
}
|
|
3926
|
-
|
|
3927
|
-
function filterLogcat(text, options) {
|
|
3928
|
-
const tags = splitCsv(options.tag || options.tags);
|
|
3929
|
-
const grep = options.grep ? String(options.grep) : '';
|
|
3930
|
-
const grepCaseSensitive = Boolean(options.grepCaseSensitive);
|
|
3931
|
-
const minLevel = priorityValue(options.level || options.minLevel || '');
|
|
3932
|
-
const pid = options.pid ? String(options.pid) : '';
|
|
3933
|
-
const requiresAppPid = options.appPid || options.packagePid || options.pid === 'current';
|
|
3934
|
-
if (requiresAppPid && !pid) {
|
|
3935
|
-
return '';
|
|
3936
|
-
}
|
|
3937
|
-
const lines = String(text || '').split(/\r?\n/);
|
|
3938
|
-
const filtered = [];
|
|
3939
|
-
let previousIncluded = false;
|
|
3940
|
-
for (const line of lines) {
|
|
3941
|
-
if (!line) continue;
|
|
3942
|
-
const parsed = parseLogcatLine(line);
|
|
3943
|
-
if (!parsed) {
|
|
3944
|
-
if (previousIncluded) filtered.push(line);
|
|
3945
|
-
continue;
|
|
3946
|
-
}
|
|
3947
|
-
let include = true;
|
|
3948
|
-
if (pid && parsed.pid !== pid) include = false;
|
|
3949
|
-
if (tags.length && !tags.includes(parsed.tag)) include = false;
|
|
3950
|
-
if (minLevel >= 0 && priorityValue(parsed.priority) < minLevel) include = false;
|
|
3951
|
-
if (grep) {
|
|
3952
|
-
include = include && (
|
|
3953
|
-
grepCaseSensitive
|
|
3954
|
-
? line.includes(grep)
|
|
3955
|
-
: line.toLowerCase().includes(grep.toLowerCase())
|
|
3956
|
-
);
|
|
3957
|
-
}
|
|
3958
|
-
previousIncluded = include;
|
|
3959
|
-
if (include) filtered.push(line);
|
|
3960
|
-
}
|
|
3961
|
-
const limit = Number(options.limitLines || options.outputLines || 0);
|
|
3962
|
-
const result = limit > 0 && filtered.length > limit ? filtered.slice(-limit) : filtered;
|
|
3963
|
-
return result.join('\n');
|
|
3964
|
-
}
|
|
3965
|
-
|
|
3966
|
-
function parseLogcatLine(line) {
|
|
3967
|
-
const match = /^\d\d-\d\d\s+\d\d:\d\d:\d\d\.\d+\s+(\d+)\s+(\d+)\s+([VDIWEAF])\s+([^:]+):\s?(.*)$/.exec(line);
|
|
3968
|
-
if (!match) return null;
|
|
3969
|
-
return {
|
|
3970
|
-
pid: match[1],
|
|
3971
|
-
tid: match[2],
|
|
3972
|
-
priority: match[3],
|
|
3973
|
-
tag: match[4].trim(),
|
|
3974
|
-
message: match[5],
|
|
3975
|
-
};
|
|
3976
|
-
}
|
|
3977
|
-
|
|
3978
|
-
function priorityValue(value) {
|
|
3979
|
-
const normalized = String(value || '').trim().toUpperCase();
|
|
3980
|
-
return { V: 0, D: 1, I: 2, W: 3, E: 4, F: 5, A: 5 }[normalized] ?? -1;
|
|
3981
|
-
}
|
|
3982
|
-
|
|
3983
|
-
function splitCsv(value) {
|
|
3984
|
-
return String(value || '').split(',').map((item) => item.trim()).filter(Boolean);
|
|
3985
|
-
}
|
|
3986
|
-
|
|
3987
|
-
function adbFollow(ctx, args, durationMs) {
|
|
3988
|
-
const allArgs = [];
|
|
3989
|
-
if (ctx.serial) allArgs.push('-s', ctx.serial);
|
|
3990
|
-
allArgs.push(...args);
|
|
3991
|
-
const boundedDurationMs = Math.max(500, Math.min(durationMs || 5000, 60000));
|
|
3992
|
-
return new Promise((resolve, reject) => {
|
|
3993
|
-
const child = spawn(ctx.adb, allArgs, { windowsHide: true });
|
|
3994
|
-
let stdout = '';
|
|
3995
|
-
let stderr = '';
|
|
3996
|
-
const timer = setTimeout(() => {
|
|
3997
|
-
child.kill();
|
|
3998
|
-
}, boundedDurationMs);
|
|
3999
|
-
child.stdout.on('data', (chunk) => {
|
|
4000
|
-
stdout += chunk.toString();
|
|
4001
|
-
});
|
|
4002
|
-
child.stderr.on('data', (chunk) => {
|
|
4003
|
-
stderr += chunk.toString();
|
|
4004
|
-
});
|
|
4005
|
-
child.on('error', (error) => {
|
|
4006
|
-
clearTimeout(timer);
|
|
4007
|
-
reject(error);
|
|
4008
|
-
});
|
|
4009
|
-
child.on('close', (code) => {
|
|
4010
|
-
clearTimeout(timer);
|
|
4011
|
-
if (code !== 0 && code !== null && stdout.length === 0) {
|
|
4012
|
-
reject(new Error(`adb logcat failed with exit code ${code}: ${stderr}`));
|
|
4013
|
-
return;
|
|
4014
|
-
}
|
|
4015
|
-
resolve(stdout);
|
|
4016
|
-
});
|
|
4017
|
-
});
|
|
4018
|
-
}
|
|
4019
|
-
|
|
4020
|
-
async function launchApp(ctx, options = {}) {
|
|
4021
|
-
if (options.component || options.activity) {
|
|
4022
|
-
return launchActivity(ctx, options);
|
|
4023
|
-
}
|
|
4024
|
-
|
|
4025
|
-
const candidates = await launcherActivityCandidates(ctx);
|
|
4026
|
-
if (candidates.length === 0) {
|
|
4027
|
-
return {
|
|
4028
|
-
ok: false,
|
|
4029
|
-
error: 'launcher_not_found',
|
|
4030
|
-
packageName: ctx.packageName,
|
|
4031
|
-
launcherCandidates: candidates,
|
|
4032
|
-
};
|
|
4033
|
-
}
|
|
4034
|
-
if (candidates.length > 1) {
|
|
4035
|
-
return {
|
|
4036
|
-
ok: false,
|
|
4037
|
-
error: 'launcher_ambiguous',
|
|
4038
|
-
packageName: ctx.packageName,
|
|
4039
|
-
launcherCandidates: candidates,
|
|
4040
|
-
suggestion: 'Pass --component or --activity to choose the intended launcher Activity.',
|
|
4041
|
-
};
|
|
4042
|
-
}
|
|
4043
|
-
|
|
4044
|
-
return startActivity(ctx, candidates[0], options, {
|
|
4045
|
-
packageName: ctx.packageName,
|
|
4046
|
-
launcherCandidates: candidates,
|
|
4047
|
-
});
|
|
4048
|
-
}
|
|
4049
|
-
|
|
4050
|
-
async function launchActivity(ctx, options = {}) {
|
|
4051
|
-
const component = normalizeActivityComponent(
|
|
4052
|
-
ctx.packageName,
|
|
4053
|
-
options.component || requiredString(options.activity, 'activity'),
|
|
4054
|
-
);
|
|
4055
|
-
return startActivity(ctx, component, options, { packageName: ctx.packageName });
|
|
4056
|
-
}
|
|
4057
|
-
|
|
4058
|
-
async function launchNativeTest(ctx) {
|
|
4059
|
-
const component = normalizeActivityComponent(ctx.packageName, ctx.nativeActivity);
|
|
4060
|
-
return startActivity(ctx, component, {}, { packageName: ctx.packageName });
|
|
4061
|
-
}
|
|
4062
|
-
|
|
4063
|
-
async function launchFlutter(ctx, initialRoute) {
|
|
4064
|
-
const component = normalizeActivityComponent(ctx.packageName, ctx.flutterActivity);
|
|
4065
|
-
const options = initialRoute ? { extra: [`ai_app_initial_route=${initialRoute}`] } : {};
|
|
4066
|
-
const result = await startActivity(ctx, component, options, { packageName: ctx.packageName });
|
|
4067
|
-
return { ...result, initialRoute };
|
|
4068
|
-
}
|
|
4069
|
-
|
|
4070
|
-
async function launcherActivityCandidates(ctx) {
|
|
4071
|
-
const result = await adb(ctx, [
|
|
4072
|
-
'shell',
|
|
4073
|
-
'cmd',
|
|
4074
|
-
'package',
|
|
4075
|
-
'query-activities',
|
|
4076
|
-
'--brief',
|
|
4077
|
-
'-a',
|
|
4078
|
-
'android.intent.action.MAIN',
|
|
4079
|
-
'-c',
|
|
4080
|
-
'android.intent.category.LAUNCHER',
|
|
4081
|
-
ctx.packageName,
|
|
4082
|
-
]);
|
|
4083
|
-
return parseLauncherActivityCandidates(result.stdout);
|
|
4084
|
-
}
|
|
4085
|
-
|
|
4086
|
-
function parseLauncherActivityCandidates(stdout) {
|
|
4087
|
-
const candidates = [];
|
|
4088
|
-
for (const line of String(stdout || '').split(/\r?\n/)) {
|
|
4089
|
-
const candidate = line.trim();
|
|
4090
|
-
if (/^[A-Za-z0-9_.$]+\/[A-Za-z0-9_.$]+$/.test(candidate) && !candidates.includes(candidate)) {
|
|
4091
|
-
candidates.push(candidate);
|
|
4092
|
-
}
|
|
4093
|
-
}
|
|
4094
|
-
return candidates;
|
|
4095
|
-
}
|
|
4096
|
-
|
|
4097
|
-
function normalizeActivityComponent(packageName, activityOrComponent) {
|
|
4098
|
-
const value = requiredString(activityOrComponent, 'activity');
|
|
4099
|
-
if (value.includes('/')) return value;
|
|
4100
|
-
return `${packageName}/${value}`;
|
|
4101
|
-
}
|
|
4102
|
-
|
|
4103
|
-
async function startActivity(ctx, component, options = {}, extraResult = {}) {
|
|
4104
|
-
const args = buildAmStartArgs(component, options);
|
|
4105
|
-
const result = await adb(ctx, args);
|
|
4106
|
-
return {
|
|
4107
|
-
ok: true,
|
|
4108
|
-
transport: 'adb',
|
|
4109
|
-
component,
|
|
4110
|
-
...extraResult,
|
|
4111
|
-
stdout: result.stdout.trim(),
|
|
4112
|
-
stderr: result.stderr.trim(),
|
|
4113
|
-
};
|
|
4114
|
-
}
|
|
4115
|
-
|
|
4116
|
-
function buildAmStartArgs(component, options = {}) {
|
|
4117
|
-
const args = ['shell', 'am', 'start'];
|
|
4118
|
-
if (options.action) args.push('-a', options.action);
|
|
4119
|
-
for (const category of optionList(options.category)) {
|
|
4120
|
-
args.push('-c', category);
|
|
4121
|
-
}
|
|
4122
|
-
if (options.data) args.push('-d', options.data);
|
|
4123
|
-
for (const extra of parseStartExtras(options.extra)) {
|
|
4124
|
-
args.push('-e', extra.key, extra.value);
|
|
4125
|
-
}
|
|
4126
|
-
args.push('-n', component);
|
|
4127
|
-
return args;
|
|
4128
|
-
}
|
|
4129
|
-
|
|
4130
|
-
function parseStartExtras(rawExtras) {
|
|
4131
|
-
return optionList(rawExtras).map((rawExtra) => {
|
|
4132
|
-
const value = String(rawExtra);
|
|
4133
|
-
const separator = value.indexOf('=');
|
|
4134
|
-
if (separator <= 0) {
|
|
4135
|
-
throw new Error('extra must use key=value');
|
|
4136
|
-
}
|
|
4137
|
-
return {
|
|
4138
|
-
key: value.slice(0, separator),
|
|
4139
|
-
value: value.slice(separator + 1),
|
|
4140
|
-
};
|
|
4141
|
-
});
|
|
4142
|
-
}
|
|
4143
|
-
|
|
4144
|
-
function optionList(value) {
|
|
4145
|
-
if (value === undefined || value === null || value === false || value === '') return [];
|
|
4146
|
-
return Array.isArray(value) ? value : [value];
|
|
4147
|
-
}
|
|
4148
|
-
|
|
4149
|
-
async function smoke(ctx, options) {
|
|
4150
|
-
const summary = {
|
|
4151
|
-
packageName: ctx.packageName,
|
|
4152
|
-
port: ctx.port,
|
|
4153
|
-
native: {},
|
|
4154
|
-
flutter: {},
|
|
4155
|
-
};
|
|
4156
|
-
|
|
4157
|
-
await adb(ctx, ['shell', 'am', 'force-stop', ctx.packageName]);
|
|
4158
|
-
await sleep(700);
|
|
4159
|
-
await launchNativeTest(ctx);
|
|
4160
|
-
await sleep(2000);
|
|
4161
|
-
|
|
4162
|
-
const status = await retry(() => bridgeGet(ctx, '/v1/status'), 10, 500);
|
|
4163
|
-
assert(status.ok, 'bridge status is ok');
|
|
4164
|
-
summary.native.statusOk = true;
|
|
4165
|
-
|
|
4166
|
-
const tree = await bridgeGet(ctx, '/v1/view/tree');
|
|
4167
|
-
assert(tree.ok, 'native tree is ok');
|
|
4168
|
-
assert(findNodeByText(tree.root, 'AiApp Native Bridge Test').node, 'native title is visible in SDK tree');
|
|
4169
|
-
assert(findNodeByText(tree.root, 'Native Increment').node, 'native increment button is visible in SDK tree');
|
|
4170
|
-
summary.native.sdkTreeNodeCount = tree.nodeCount;
|
|
4171
|
-
|
|
4172
|
-
const uiaXml = await uiaTree(ctx);
|
|
4173
|
-
assert(uiaXml.includes('AiApp Native Bridge Test'), 'uiautomator tree contains native title');
|
|
4174
|
-
summary.native.uiaTreeOk = true;
|
|
4175
|
-
|
|
4176
|
-
const h5Dom = await bridgeGet(ctx, '/v1/h5/dom');
|
|
4177
|
-
assert(h5Dom.ok, 'native WebView DOM endpoint is ok');
|
|
4178
|
-
assert(h5Dom.dom?.bodyText?.includes('H5 DOM snapshot body text'), 'native WebView DOM contains body text');
|
|
4179
|
-
assert((h5Dom.dom?.controlCount || 0) >= 2, 'native WebView DOM contains controls');
|
|
4180
|
-
summary.native.h5DomControlCount = h5Dom.dom.controlCount;
|
|
4181
|
-
|
|
4182
|
-
const h5ClickResult = await h5Click(ctx, { selector: '#native-h5-button' });
|
|
4183
|
-
assert(h5ClickResult.ok, 'native WebView h5-click endpoint is ok');
|
|
4184
|
-
assert(JSON.stringify(h5ClickResult.result).includes('Native H5 clicked'), 'native WebView h5-click changed DOM');
|
|
4185
|
-
const h5WaitResult = await h5Wait(ctx, { targetText: 'Native H5 clicked', timeoutSec: 5 });
|
|
4186
|
-
assert(h5WaitResult.ok, 'native WebView h5-wait observes clicked text');
|
|
4187
|
-
const h5InputResult = await h5Input(ctx, { selector: '#native-h5-input', value: 'ai_app h5 input' });
|
|
4188
|
-
assert(h5InputResult.ok, 'native WebView h5-input endpoint is ok');
|
|
4189
|
-
assert(h5InputResult.result?.value === 'ai_app h5 input', 'native WebView h5-input changed input value');
|
|
4190
|
-
await keyevent(ctx, 111);
|
|
4191
|
-
await sleep(300);
|
|
4192
|
-
const h5ScrollResult = await h5Scroll(ctx, { selector: '#native-h5-input' });
|
|
4193
|
-
assert(h5ScrollResult.ok, 'native WebView h5-scroll endpoint is ok');
|
|
4194
|
-
const h5DomAfterClick = await bridgeGet(ctx, '/v1/h5/dom');
|
|
4195
|
-
assert(JSON.stringify(h5DomAfterClick.dom).includes('Native H5 clicked'), 'native WebView DOM read sees click result');
|
|
4196
|
-
assert(JSON.stringify(h5DomAfterClick.dom).includes('ai_app h5 input'), 'native WebView DOM read sees input result');
|
|
4197
|
-
summary.native.h5ClickOk = true;
|
|
4198
|
-
summary.native.h5InputOk = true;
|
|
4199
|
-
summary.native.h5WaitOk = true;
|
|
4200
|
-
summary.native.h5ScrollOk = true;
|
|
4201
|
-
|
|
4202
|
-
const webviewProbeUrl = `http://127.0.0.1:${ctx.devicePort || ctx.port}/v1/status?from=webview-cdp-smoke`;
|
|
4203
|
-
const webviewCdp = await webviewCdpCapture(ctx, {
|
|
4204
|
-
durationMs: 3000,
|
|
4205
|
-
pageUrlFilter: 'native-webview',
|
|
4206
|
-
urlFilter: 'webview-cdp-smoke',
|
|
4207
|
-
includeResponseBody: true,
|
|
4208
|
-
script: `(() => { const url = ${JSON.stringify(webviewProbeUrl)}; console.log('ai-bridge-webview-cdp-console', url); fetch(url).then((response) => { console.log('ai-bridge-webview-cdp-response', response.status); return response.text(); }).catch((error) => console.log('ai-bridge-webview-cdp-error', error.name + ':' + error.message)); return url; })()`,
|
|
4209
|
-
});
|
|
4210
|
-
const webviewCdpText = JSON.stringify(webviewCdp);
|
|
4211
|
-
assert(webviewCdp.requests.some((item) => String(item.url || item.responseUrl || '').includes('webview-cdp-smoke')), 'WebView CDP captured H5 network request');
|
|
4212
|
-
assert(webviewCdpText.includes('ai-bridge-webview-cdp-console'), 'WebView CDP captured console output');
|
|
4213
|
-
summary.native.webviewCdpOk = true;
|
|
4214
|
-
summary.native.webviewCdpCapture = webviewCdp.counts;
|
|
4215
|
-
|
|
4216
|
-
const artifactPrefix = 'ai_app_bridge_smoke_screenshot';
|
|
4217
|
-
const screenshotPath = screenshotOutputPath(options, artifactPrefix);
|
|
4218
|
-
const screenshotResult = await screenshot(ctx, screenshotPath, { ...options, artifactPrefix });
|
|
4219
|
-
assert(screenshotResult.width > 0 && screenshotResult.height > 0, 'adb screenshot has size');
|
|
4220
|
-
summary.native.screenshot = {
|
|
4221
|
-
width: screenshotResult.width,
|
|
4222
|
-
height: screenshotResult.height,
|
|
4223
|
-
path: screenshotResult.path,
|
|
4224
|
-
artifact: screenshotResult.artifact,
|
|
4225
|
-
};
|
|
4226
|
-
|
|
4227
|
-
await tapText(ctx, 'Native Increment');
|
|
4228
|
-
await sleep(700);
|
|
4229
|
-
const treeAfterTap = await bridgeGet(ctx, '/v1/view/tree');
|
|
4230
|
-
assert(findNodeByText(treeAfterTap.root, 'Native counter: 1').node, 'tap changed native counter');
|
|
4231
|
-
summary.native.tapChangedCounter = true;
|
|
4232
|
-
|
|
4233
|
-
await tapText(ctx, 'native_input');
|
|
4234
|
-
await keyevent(ctx, 123);
|
|
4235
|
-
await inputText(ctx, 'ai_appsmoke');
|
|
4236
|
-
await keyevent(ctx, 111);
|
|
4237
|
-
await sleep(700);
|
|
4238
|
-
const treeAfterInput = await bridgeGet(ctx, '/v1/view/tree');
|
|
4239
|
-
assert(JSON.stringify(treeAfterInput.root).includes('ai_appsmoke'), 'adb input changed native text field');
|
|
4240
|
-
summary.native.inputTextOk = true;
|
|
4241
|
-
|
|
4242
|
-
const eventsBefore = await bridgeGet(ctx, withQuery('/v1/events', { limit: 1 }));
|
|
4243
|
-
const sinceEventId = lastItem(eventsBefore.items)?.id || 0;
|
|
4244
|
-
|
|
4245
|
-
await tapText(ctx, 'Record Log');
|
|
4246
|
-
await sleep(300);
|
|
4247
|
-
await tapText(ctx, 'Record Network');
|
|
4248
|
-
await sleep(300);
|
|
4249
|
-
await tapText(ctx, 'Record State');
|
|
4250
|
-
await sleep(300);
|
|
4251
|
-
await tapText(ctx, 'Record Event');
|
|
4252
|
-
await sleep(300);
|
|
4253
|
-
|
|
4254
|
-
const listTree = await bridgeGet(ctx, '/v1/view/tree');
|
|
4255
|
-
assert(JSON.stringify(listTree.root).includes('Native List Row 24'), 'native long list row is present in bridge tree');
|
|
4256
|
-
summary.native.longListTreeOk = true;
|
|
4257
|
-
|
|
4258
|
-
await tapText(ctx, 'Open Dialog');
|
|
4259
|
-
const dialogWait = await waitText(ctx, 'Native Dialog Title', 8);
|
|
4260
|
-
assert(dialogWait.ok, 'native dialog title appeared');
|
|
4261
|
-
await tapText(ctx, 'DIALOG CONFIRM');
|
|
4262
|
-
await sleep(500);
|
|
4263
|
-
|
|
4264
|
-
const logs = await bridgeGet(ctx, '/v1/logs');
|
|
4265
|
-
const network = await bridgeGet(ctx, '/v1/network');
|
|
4266
|
-
const state = await bridgeGet(ctx, '/v1/state');
|
|
4267
|
-
const events = await bridgeGet(ctx, '/v1/events');
|
|
4268
|
-
assert(JSON.stringify(logs.items).includes('NativeBridgeTest'), 'logs endpoint contains native test entries');
|
|
4269
|
-
assert(JSON.stringify(network.items).includes('https://debug.local/native-test'), 'network endpoint contains native test request');
|
|
4270
|
-
assert(JSON.stringify(state.values).includes('native_test.screen'), 'state endpoint contains native test state');
|
|
4271
|
-
assert(JSON.stringify(events.items).includes('dialog_confirmed'), 'events endpoint contains dialog confirmation');
|
|
4272
|
-
summary.native.dialogOk = true;
|
|
4273
|
-
summary.native.capture = { logs: logs.count, network: network.count, state: state.count, events: events.count };
|
|
4274
|
-
|
|
4275
|
-
const limitedLogs = await bridgeGet(ctx, withQuery('/v1/logs', { limit: 1 }));
|
|
4276
|
-
const eventsSince = await bridgeGet(ctx, withQuery('/v1/events', { sinceId: sinceEventId, limit: 20 }));
|
|
4277
|
-
assert(limitedLogs.count <= 1 && limitedLogs.limit === 1, 'logs endpoint honors limit query');
|
|
4278
|
-
assert(JSON.stringify(eventsSince.items).includes('dialog_confirmed'), 'events endpoint honors sinceId query');
|
|
4279
|
-
summary.native.captureQueryOk = true;
|
|
4280
|
-
|
|
4281
|
-
const microphonePermission = 'android.permission.RECORD_AUDIO';
|
|
4282
|
-
const microphoneBefore = await permissionState(ctx, microphonePermission);
|
|
4283
|
-
if (microphoneBefore.ok && !microphoneBefore.granted) {
|
|
4284
|
-
await tapText(ctx, 'Request Microphone Permission');
|
|
4285
|
-
const permissionResult = await permissionDialog(ctx, {
|
|
4286
|
-
attempts: 10,
|
|
4287
|
-
intervalMs: 500,
|
|
4288
|
-
resourceId: 'permission_allow_one_time_button,permission_allow_foreground_only_button,permission_allow_button',
|
|
4289
|
-
});
|
|
4290
|
-
assert(permissionResult.ok, 'permission dialog allow button was tapped');
|
|
4291
|
-
await sleep(1000);
|
|
4292
|
-
const grantedMicrophone = await permissionState(ctx, microphonePermission);
|
|
4293
|
-
assert(grantedMicrophone.ok && grantedMicrophone.granted, 'microphone permission is granted after dialog handling');
|
|
4294
|
-
summary.native.permissionDialogOk = true;
|
|
4295
|
-
summary.native.permissionState = {
|
|
4296
|
-
permission: microphonePermission,
|
|
4297
|
-
granted: grantedMicrophone.granted,
|
|
4298
|
-
matched: permissionResult.matched,
|
|
4299
|
-
};
|
|
4300
|
-
} else {
|
|
4301
|
-
summary.native.permissionDialogSkipped = microphoneBefore.ok ? 'already_granted' : microphoneBefore.error;
|
|
4302
|
-
}
|
|
4303
|
-
|
|
4304
|
-
let scrolledUiaXml = '';
|
|
4305
|
-
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
4306
|
-
scrolledUiaXml = await uiaTree(ctx);
|
|
4307
|
-
if (scrolledUiaXml.includes('Native List Row 24') || scrolledUiaXml.includes('Finish')) {
|
|
4308
|
-
break;
|
|
4309
|
-
}
|
|
4310
|
-
await swipe(ctx, 540, 2100, 540, 500, 700);
|
|
4311
|
-
await sleep(800);
|
|
4312
|
-
}
|
|
4313
|
-
if (!scrolledUiaXml.includes('Native List Row 24') && !scrolledUiaXml.includes('Finish')) {
|
|
4314
|
-
throw new Error('native list bottom is not visible after repeated swipes');
|
|
4315
|
-
}
|
|
4316
|
-
summary.native.scrollOk = scrolledUiaXml.includes('Native List Row 24') || scrolledUiaXml.includes('Finish');
|
|
4317
|
-
|
|
4318
|
-
let backLeftNative = false;
|
|
4319
|
-
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
4320
|
-
await keyevent(ctx, 4);
|
|
4321
|
-
await sleep(700);
|
|
4322
|
-
try {
|
|
4323
|
-
const statusAfterBack = await bridgeGet(ctx, '/v1/status');
|
|
4324
|
-
if (!String(statusAfterBack.activity?.current || '').includes('DebugBridgeNativeTestActivity')) {
|
|
4325
|
-
backLeftNative = true;
|
|
4326
|
-
break;
|
|
4327
|
-
}
|
|
4328
|
-
} catch (_) {
|
|
4329
|
-
backLeftNative = true;
|
|
4330
|
-
break;
|
|
4331
|
-
}
|
|
4332
|
-
}
|
|
4333
|
-
assert(backLeftNative, 'back left native debug activity');
|
|
4334
|
-
summary.native.backOk = true;
|
|
4335
|
-
|
|
4336
|
-
if (!options.skipFlutterLaunch) {
|
|
4337
|
-
await launchFlutter(ctx, '');
|
|
4338
|
-
const flutterStatus = await waitFlutterLayout(ctx, 20);
|
|
4339
|
-
const layout = flutterStatus.flutter?.layout;
|
|
4340
|
-
assert(layout, 'flutter snapshot has layout');
|
|
4341
|
-
assert(layout.widgetDump?.ok === true, 'flutter widget dump is ok');
|
|
4342
|
-
assert(String(layout.widgetDump?.text || '').length > 0, 'flutter widget dump has text');
|
|
4343
|
-
summary.flutter.widgetDumpOk = true;
|
|
4344
|
-
summary.flutter.widgetDumpLength = layout.widgetDump.length;
|
|
4345
|
-
|
|
4346
|
-
await flutterAction(ctx, { action: 'openHarness' });
|
|
4347
|
-
await sleep(1000);
|
|
4348
|
-
const flutterLogsBefore = await bridgeGet(ctx, withQuery('/v1/logs', { limit: 1 }));
|
|
4349
|
-
const flutterNetworkBefore = await bridgeGet(ctx, withQuery('/v1/network', { limit: 1 }));
|
|
4350
|
-
const sinceFlutterLogId = lastItem(flutterLogsBefore.items)?.id || 0;
|
|
4351
|
-
const sinceFlutterNetworkId = lastItem(flutterNetworkBefore.items)?.id || 0;
|
|
4352
|
-
|
|
4353
|
-
await flutterAction(ctx, { action: 'tapText', text: 'Record Auto Log Fixture' });
|
|
4354
|
-
await sleep(800);
|
|
4355
|
-
const flutterAutoLogs = await bridgeGet(ctx, withQuery('/v1/logs', { sinceId: sinceFlutterLogId, limit: 20 }));
|
|
4356
|
-
assert(JSON.stringify(flutterAutoLogs.items).includes('ai_app auto debugPrint fixture'), 'flutter debugPrint auto log is captured');
|
|
4357
|
-
assert(JSON.stringify(flutterAutoLogs.items).includes('ai_app auto flutter error fixture'), 'flutter FlutterError auto log is captured');
|
|
4358
|
-
summary.flutter.autoLogCaptureOk = true;
|
|
4359
|
-
|
|
4360
|
-
await flutterAction(ctx, { action: 'tapText', text: 'Run Dart HttpClient Fixture' });
|
|
4361
|
-
await sleep(1200);
|
|
4362
|
-
const flutterAutoNetwork = await bridgeGet(ctx, withQuery('/v1/network', { sinceId: sinceFlutterNetworkId, limit: 20 }));
|
|
4363
|
-
const flutterAutoNetworkText = JSON.stringify(flutterAutoNetwork.items);
|
|
4364
|
-
assert(flutterAutoNetworkText.includes('flutter-httpclient-auto'), 'flutter HttpClient auto network source is captured');
|
|
4365
|
-
assert(flutterAutoNetworkText.includes('/v1/events'), 'flutter HttpClient auto network URL is captured');
|
|
4366
|
-
assert(flutterAutoNetworkText.includes('dart_httpclient_fixture'), 'flutter HttpClient auto request body is captured');
|
|
4367
|
-
summary.flutter.autoHttpClientCaptureOk = true;
|
|
4368
|
-
|
|
4369
|
-
summary.flutter.h5DomTested = false;
|
|
4370
|
-
summary.flutter.h5DomNote = 'Flutter WebView DOM requires a generic WebView adapter or controller registry, not app-specific route code.';
|
|
4371
|
-
}
|
|
4372
|
-
|
|
4373
|
-
summary.ok = true;
|
|
4374
|
-
return summary;
|
|
4375
|
-
}
|
|
4376
|
-
|
|
4377
|
-
async function waitFlutterLayout(ctx, attempts) {
|
|
4378
|
-
for (let index = 0; index < attempts; index += 1) {
|
|
4379
|
-
const status = await bridgeGet(ctx, '/v1/status');
|
|
4380
|
-
if (status.flutter?.layout?.widgetDump?.ok === true) {
|
|
4381
|
-
return status;
|
|
4382
|
-
}
|
|
4383
|
-
await sleep(700);
|
|
4384
|
-
}
|
|
4385
|
-
return bridgeGet(ctx, '/v1/status');
|
|
4386
|
-
}
|
|
4387
|
-
|
|
4388
|
-
async function retry(action, attempts, delayMs) {
|
|
4389
|
-
let lastError;
|
|
4390
|
-
for (let index = 0; index < attempts; index += 1) {
|
|
4391
|
-
try {
|
|
4392
|
-
return await action();
|
|
4393
|
-
} catch (error) {
|
|
4394
|
-
lastError = error;
|
|
4395
|
-
await sleep(delayMs);
|
|
4396
|
-
}
|
|
4397
|
-
}
|
|
4398
|
-
throw lastError;
|
|
4399
|
-
}
|
|
4400
|
-
|
|
4401
|
-
function assert(condition, message) {
|
|
4402
|
-
if (!condition) throw new Error(`ASSERT FAILED: ${message}`);
|
|
4403
|
-
}
|
|
4404
|
-
|
|
4405
|
-
function lastItem(items) {
|
|
4406
|
-
return Array.isArray(items) && items.length > 0 ? items[items.length - 1] : null;
|
|
4407
|
-
}
|
|
4408
|
-
|
|
4409
|
-
function sleep(ms) {
|
|
4410
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4411
|
-
}
|
|
4412
|
-
|
|
4413
|
-
function uiautomatorLockPath(ctx = {}) {
|
|
4414
|
-
const key = sanitizeLockKey([
|
|
4415
|
-
ctx.serial || 'default',
|
|
4416
|
-
ctx.adb || defaults.adb,
|
|
4417
|
-
].join('-'));
|
|
4418
|
-
return path.join(os.tmpdir(), `ai-app-bridge-uiautomator-${key}.lock`);
|
|
4419
|
-
}
|
|
4420
|
-
|
|
4421
|
-
function sanitizeLockKey(value) {
|
|
4422
|
-
return String(value || 'default').replace(/[^a-zA-Z0-9_.-]+/g, '_').slice(0, 120) || 'default';
|
|
4423
|
-
}
|
|
4424
|
-
|
|
4425
|
-
async function withFileLock(lockPath, action, options = {}) {
|
|
4426
|
-
const timeoutMs = Number(options.timeoutMs || 30000);
|
|
4427
|
-
const staleMs = Number(options.staleMs || 120000);
|
|
4428
|
-
const pollMs = Number(options.pollMs || 100);
|
|
4429
|
-
const startMs = Date.now();
|
|
4430
|
-
let handle = null;
|
|
4431
|
-
|
|
4432
|
-
while (handle === null) {
|
|
4433
|
-
try {
|
|
4434
|
-
handle = fs.openSync(lockPath, 'wx');
|
|
4435
|
-
try {
|
|
4436
|
-
fs.writeFileSync(handle, JSON.stringify({ pid: process.pid, createdAtMs: Date.now() }));
|
|
4437
|
-
} catch (writeError) {
|
|
4438
|
-
try {
|
|
4439
|
-
fs.closeSync(handle);
|
|
4440
|
-
} catch (_) {
|
|
4441
|
-
// Ignore close failure while unwinding the lock creation failure.
|
|
4442
|
-
}
|
|
4443
|
-
try {
|
|
4444
|
-
fs.unlinkSync(lockPath);
|
|
4445
|
-
} catch (_) {
|
|
4446
|
-
// Ignore cleanup failure while preserving the original write error.
|
|
4447
|
-
}
|
|
4448
|
-
handle = null;
|
|
4449
|
-
throw writeError;
|
|
4450
|
-
}
|
|
4451
|
-
} catch (error) {
|
|
4452
|
-
if (error && error.code === 'EEXIST') {
|
|
4453
|
-
removeStaleLock(lockPath, staleMs);
|
|
4454
|
-
if (Date.now() - startMs >= timeoutMs) {
|
|
4455
|
-
const timeout = new Error(`timed out waiting for lock: ${lockPath}`);
|
|
4456
|
-
timeout.code = 'LOCK_TIMEOUT';
|
|
4457
|
-
throw timeout;
|
|
4458
|
-
}
|
|
4459
|
-
await sleep(pollMs);
|
|
4460
|
-
continue;
|
|
4461
|
-
}
|
|
4462
|
-
throw error;
|
|
4463
|
-
}
|
|
4464
|
-
}
|
|
4465
|
-
|
|
4466
|
-
try {
|
|
4467
|
-
return await action();
|
|
4468
|
-
} finally {
|
|
4469
|
-
try {
|
|
4470
|
-
fs.closeSync(handle);
|
|
4471
|
-
} catch (_) {
|
|
4472
|
-
// Ignore close failures; unlink below is the operation that releases the lock for peers.
|
|
4473
|
-
}
|
|
4474
|
-
try {
|
|
4475
|
-
fs.unlinkSync(lockPath);
|
|
4476
|
-
} catch (_) {
|
|
4477
|
-
// A stale-lock cleanup racing with process shutdown should not mask the original result.
|
|
4478
|
-
}
|
|
4479
|
-
}
|
|
4480
|
-
}
|
|
4481
|
-
|
|
4482
|
-
function removeStaleLock(lockPath, staleMs) {
|
|
4483
|
-
try {
|
|
4484
|
-
const stat = fs.statSync(lockPath);
|
|
4485
|
-
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
4486
|
-
fs.unlinkSync(lockPath);
|
|
4487
|
-
}
|
|
4488
|
-
} catch (_) {
|
|
4489
|
-
// Another process may have released the lock between the exists check and stat/unlink.
|
|
4490
|
-
}
|
|
4491
|
-
}
|
|
4492
|
-
|
|
4493
|
-
function requiredString(value, name) {
|
|
4494
|
-
if (typeof value !== 'string' || value.length === 0) throw new Error(`${name} is required`);
|
|
4495
|
-
return value;
|
|
4496
|
-
}
|
|
4497
|
-
|
|
4498
|
-
function requiredNumber(value, name) {
|
|
4499
|
-
const number = Number(value);
|
|
4500
|
-
if (!Number.isFinite(number)) throw new Error(`${name} is required`);
|
|
4501
|
-
return number;
|
|
4502
|
-
}
|
|
4503
|
-
|
|
4504
|
-
module.exports = {
|
|
4505
|
-
buildBridgeFailureResult,
|
|
4506
|
-
clearAppDataAdbArgs,
|
|
4507
|
-
defaultInstallerButtonTexts,
|
|
4508
|
-
artifactTimestamp,
|
|
4509
|
-
compactBridgeTree,
|
|
4510
|
-
compactStatus,
|
|
4511
|
-
compactUiaTree,
|
|
4512
|
-
defaultArtifactDirectory,
|
|
4513
|
-
defaultArtifactPath,
|
|
4514
|
-
findFlutterNode,
|
|
4515
|
-
findTappableNodeByText,
|
|
4516
|
-
filterLogcat,
|
|
4517
|
-
firstErrorLine,
|
|
4518
|
-
flutterNodePoint,
|
|
4519
|
-
flutterPhysicalViewport,
|
|
4520
|
-
helpText,
|
|
4521
|
-
installerButtonTextsForSurface,
|
|
4522
|
-
isAdbInputTextSafe,
|
|
4523
|
-
isLikelyInstallerSurface,
|
|
4524
|
-
nodeTapState,
|
|
4525
|
-
normalizeBridgeError,
|
|
4526
|
-
normalizeActivityComponent,
|
|
4527
|
-
parseWebViewDevToolsSockets,
|
|
4528
|
-
parseArgs,
|
|
4529
|
-
parseKeyboardState,
|
|
4530
|
-
parsePackagePidsFromPs,
|
|
4531
|
-
parseLauncherActivityCandidates,
|
|
4532
|
-
parseStartExtras,
|
|
4533
|
-
parseUiaBounds,
|
|
4534
|
-
parseUiaViewport,
|
|
4535
|
-
parseComponentFromWindowLine,
|
|
4536
|
-
parseForegroundWindow,
|
|
4537
|
-
chooseWebViewDevToolsSocket,
|
|
4538
|
-
chooseWebViewPage,
|
|
4539
|
-
shapeNetworkCapture,
|
|
4540
|
-
compactNetworkRecord,
|
|
4541
|
-
pruneGeneratedArtifacts,
|
|
4542
|
-
shouldSkipInstallerTapForInstalledPackage,
|
|
4543
|
-
shouldDismissKeyboardForPoint,
|
|
4544
|
-
shouldUseDefaultPortFallback,
|
|
4545
|
-
screenshotOutputPath,
|
|
4546
|
-
statusSearchText,
|
|
4547
|
-
uiautomatorLockPath,
|
|
4548
|
-
verifyBridgeTargetPackage,
|
|
4549
|
-
waitTextConditionsMet,
|
|
4550
|
-
withFileLock,
|
|
4551
|
-
};
|
|
97
|
+
if (require.main === module) main();
|
|
98
|
+
module.exports = { parseArgs, helpText };
|