zig-mobile-runner 0.1.0
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/CHANGELOG.md +484 -0
- package/CONTRIBUTING.md +42 -0
- package/FEATURES.md +112 -0
- package/LICENSE +21 -0
- package/README.md +255 -0
- package/SECURITY.md +34 -0
- package/build.zig +38 -0
- package/build.zig.zon +7 -0
- package/clients/README.md +144 -0
- package/clients/go/README.md +24 -0
- package/clients/go/examples/fake-session/main.go +93 -0
- package/clients/go/go.mod +3 -0
- package/clients/go/zmr/client.go +432 -0
- package/clients/kotlin/README.md +35 -0
- package/clients/kotlin/build.gradle.kts +35 -0
- package/clients/kotlin/settings.gradle.kts +15 -0
- package/clients/kotlin/src/main/kotlin/dev/zmr/FakeSession.kt +86 -0
- package/clients/kotlin/src/main/kotlin/dev/zmr/ZmrClient.kt +67 -0
- package/clients/python/README.md +29 -0
- package/clients/python/examples/fake_session.py +48 -0
- package/clients/python/pyproject.toml +13 -0
- package/clients/python/zmr_client.py +202 -0
- package/clients/rust/Cargo.lock +107 -0
- package/clients/rust/Cargo.toml +10 -0
- package/clients/rust/README.md +19 -0
- package/clients/rust/examples/fake_session.rs +70 -0
- package/clients/rust/src/lib.rs +461 -0
- package/clients/swift/Package.swift +16 -0
- package/clients/swift/README.md +36 -0
- package/clients/swift/Sources/ZMRClient/ZMRClient.swift +114 -0
- package/clients/swift/Sources/ZMRFakeSession/main.swift +86 -0
- package/clients/typescript/README.md +34 -0
- package/clients/typescript/examples/fake-session.mjs +36 -0
- package/clients/typescript/index.d.ts +144 -0
- package/clients/typescript/index.mjs +192 -0
- package/clients/typescript/package.json +8 -0
- package/docs/adr/0001-agent-native-runner-boundary.md +31 -0
- package/docs/adr/0002-app-local-zmr-contract.md +39 -0
- package/docs/adr/0003-ios-simulator-xctest-shim.md +41 -0
- package/docs/adr/0004-benchmark-claims-and-baseline-collection.md +37 -0
- package/docs/adr/README.md +12 -0
- package/docs/ai-agents.md +156 -0
- package/docs/app-integration.md +316 -0
- package/docs/benchmarking.md +275 -0
- package/docs/client-installation.md +141 -0
- package/docs/clients.md +98 -0
- package/docs/config.md +175 -0
- package/docs/demo.md +259 -0
- package/docs/dsl.md +57 -0
- package/docs/install.md +233 -0
- package/docs/market-positioning.md +70 -0
- package/docs/npm.md +359 -0
- package/docs/protocol-fixtures/README.md +8 -0
- package/docs/protocol-fixtures/core-session.requests.jsonl +8 -0
- package/docs/protocol-fixtures/core-session.responses.jsonl +8 -0
- package/docs/protocol-versioning.md +65 -0
- package/docs/protocol.md +560 -0
- package/docs/publication.md +77 -0
- package/docs/release-audit.md +99 -0
- package/docs/release-candidate.md +111 -0
- package/docs/release-evidence.md +188 -0
- package/docs/release-notes-template.md +58 -0
- package/docs/roadmap.md +334 -0
- package/docs/scenario-authoring.md +88 -0
- package/docs/shipping.md +170 -0
- package/docs/trace-privacy.md +88 -0
- package/docs/troubleshooting.md +256 -0
- package/examples/android-app-auth-probe.json +89 -0
- package/examples/android-app-error-state.json +13 -0
- package/examples/android-app-login-smoke.json +192 -0
- package/examples/android-app-onboarding.json +12 -0
- package/examples/android-app-referral-deep-link.json +12 -0
- package/examples/android-shim-smoke.json +19 -0
- package/examples/demo-failure.json +12 -0
- package/examples/demo-fake.json +14 -0
- package/examples/ios-dev-client-open-link.json +26 -0
- package/examples/ios-dev-client-route-snapshot.json +24 -0
- package/examples/ios-shim-smoke.json +23 -0
- package/examples/ios-smoke.json +9 -0
- package/go.work +3 -0
- package/npm/agents.mjs +183 -0
- package/npm/app-config.mjs +95 -0
- package/npm/build-zmr.mjs +21 -0
- package/npm/commands.mjs +104 -0
- package/npm/generated-files.mjs +50 -0
- package/npm/index.mjs +75 -0
- package/npm/init-app.mjs +80 -0
- package/npm/package-scripts.mjs +72 -0
- package/npm/postinstall.mjs +21 -0
- package/npm/scaffold.mjs +179 -0
- package/npm/scenarios.mjs +93 -0
- package/npm/setup.mjs +69 -0
- package/npm/wizard.mjs +117 -0
- package/npm/zmr.mjs +23 -0
- package/package.json +114 -0
- package/prebuilds/darwin-arm64/zmr +0 -0
- package/prebuilds/darwin-x64/zmr +0 -0
- package/prebuilds/linux-arm64/zmr +0 -0
- package/prebuilds/linux-x64/zmr +0 -0
- package/schemas/README.md +26 -0
- package/schemas/action-result.schema.json +27 -0
- package/schemas/capabilities-output.schema.json +98 -0
- package/schemas/devices-output.schema.json +25 -0
- package/schemas/doctor-output.schema.json +51 -0
- package/schemas/explain-output.schema.json +51 -0
- package/schemas/import-output.schema.json +23 -0
- package/schemas/init-output.schema.json +71 -0
- package/schemas/json-rpc.schema.json +55 -0
- package/schemas/release-manifest.schema.json +43 -0
- package/schemas/release-readiness-output.schema.json +127 -0
- package/schemas/run-output.schema.json +43 -0
- package/schemas/scenario.schema.json +128 -0
- package/schemas/schemas-output.schema.json +26 -0
- package/schemas/semantic-snapshot.schema.json +116 -0
- package/schemas/snapshot.schema.json +60 -0
- package/schemas/trace-event.schema.json +14 -0
- package/schemas/trace-manifest.schema.json +59 -0
- package/schemas/validate-output.schema.json +42 -0
- package/schemas/version-output.schema.json +23 -0
- package/schemas/zmr-config.schema.json +75 -0
- package/scripts/android-emulator.sh +126 -0
- package/scripts/assert-ios-physical-ready.sh +213 -0
- package/scripts/benchmark-command.sh +307 -0
- package/scripts/benchmark.sh +359 -0
- package/scripts/benchmark_gate.py +117 -0
- package/scripts/benchmark_result_row.py +88 -0
- package/scripts/compare-benchmarks.py +288 -0
- package/scripts/create-android-demo-app.sh +342 -0
- package/scripts/create-ios-demo-app.sh +261 -0
- package/scripts/demo-android-real.sh +232 -0
- package/scripts/demo-ios-real.sh +270 -0
- package/scripts/demo.sh +464 -0
- package/scripts/device-matrix.sh +338 -0
- package/scripts/ensure-ios-shim-target.rb +237 -0
- package/scripts/install-android-shim.sh +281 -0
- package/scripts/install-ios-shim.sh +589 -0
- package/scripts/pilot-gate.sh +560 -0
- package/scripts/release-readiness.py +838 -0
- package/scripts/release-readiness.sh +91 -0
- package/scripts/run-android-pilot.sh +561 -0
- package/scripts/run-ios-pilot.sh +509 -0
- package/shims/android/README.md +21 -0
- package/shims/android/ZMRShimInstrumentedTest.java +152 -0
- package/shims/android/protocol.md +18 -0
- package/shims/ios/README.md +50 -0
- package/shims/ios/ZMRShim.swift +110 -0
- package/shims/ios/ZMRShimUITestCase.swift +475 -0
- package/shims/ios/protocol.md +74 -0
- package/skills/zmr-mobile-testing/SKILL.md +127 -0
- package/src/android.zig +344 -0
- package/src/android_device_info.zig +99 -0
- package/src/android_emulator.zig +154 -0
- package/src/android_screen_recording.zig +112 -0
- package/src/android_shell.zig +112 -0
- package/src/bundle.zig +124 -0
- package/src/bundle_redaction.zig +272 -0
- package/src/bundle_tar.zig +123 -0
- package/src/cli_devices.zig +97 -0
- package/src/cli_doctor.zig +114 -0
- package/src/cli_import.zig +70 -0
- package/src/cli_info.zig +39 -0
- package/src/cli_init.zig +72 -0
- package/src/cli_output.zig +467 -0
- package/src/cli_run.zig +259 -0
- package/src/cli_serve.zig +287 -0
- package/src/cli_trace.zig +111 -0
- package/src/cli_validate.zig +41 -0
- package/src/command.zig +211 -0
- package/src/config.zig +305 -0
- package/src/config_diagnostics.zig +212 -0
- package/src/config_paths.zig +49 -0
- package/src/device_registry.zig +37 -0
- package/src/doctor.zig +412 -0
- package/src/doctor_hints.zig +52 -0
- package/src/errors.zig +55 -0
- package/src/fake_device.zig +163 -0
- package/src/health.zig +28 -0
- package/src/importer.zig +343 -0
- package/src/importer_json.zig +100 -0
- package/src/importer_model.zig +103 -0
- package/src/ios.zig +399 -0
- package/src/ios_devices.zig +219 -0
- package/src/ios_lifecycle.zig +72 -0
- package/src/ios_shim.zig +242 -0
- package/src/ios_snapshot.zig +20 -0
- package/src/json_fields.zig +80 -0
- package/src/json_rpc.zig +150 -0
- package/src/json_rpc_methods.zig +318 -0
- package/src/json_rpc_observation.zig +31 -0
- package/src/json_rpc_params.zig +52 -0
- package/src/json_rpc_protocol.zig +110 -0
- package/src/json_rpc_trace.zig +73 -0
- package/src/main.zig +135 -0
- package/src/mcp.zig +234 -0
- package/src/mcp_protocol.zig +64 -0
- package/src/mcp_trace.zig +83 -0
- package/src/report.zig +346 -0
- package/src/report_html.zig +63 -0
- package/src/report_values.zig +27 -0
- package/src/run_options.zig +152 -0
- package/src/runner.zig +280 -0
- package/src/runner_actions.zig +109 -0
- package/src/runner_config.zig +6 -0
- package/src/runner_diagnostics.zig +268 -0
- package/src/runner_events.zig +170 -0
- package/src/runner_native.zig +88 -0
- package/src/runner_waits.zig +300 -0
- package/src/scaffold.zig +472 -0
- package/src/scenario.zig +346 -0
- package/src/scenario_fields.zig +50 -0
- package/src/schema_registry.zig +53 -0
- package/src/selector.zig +84 -0
- package/src/semantic.zig +171 -0
- package/src/trace.zig +315 -0
- package/src/trace_json.zig +340 -0
- package/src/trace_summary.zig +218 -0
- package/src/trace_summary_diagnostic.zig +202 -0
- package/src/types.zig +120 -0
- package/src/uiautomator.zig +164 -0
- package/src/validation.zig +187 -0
- package/src/version.zig +22 -0
- package/viewer/app.js +373 -0
- package/viewer/index.html +126 -0
- package/viewer/parser.js +233 -0
- package/viewer/styles.css +585 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Android Shim Protocol
|
|
2
|
+
|
|
3
|
+
The Android shim protocol is internal and may change before `v1.0.0`.
|
|
4
|
+
|
|
5
|
+
The first implementation should mirror the public ZMR action model:
|
|
6
|
+
|
|
7
|
+
- `snapshot`
|
|
8
|
+
- `tap`
|
|
9
|
+
- `type`
|
|
10
|
+
- `eraseText`
|
|
11
|
+
- `hideKeyboard`
|
|
12
|
+
- `swipe`
|
|
13
|
+
- `pressBack`
|
|
14
|
+
- `settle`
|
|
15
|
+
- `appState`
|
|
16
|
+
|
|
17
|
+
The Zig adapter must keep ADB/UI Automator fallback behavior so the runner can
|
|
18
|
+
still operate when the shim is not installed.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# ZMR iOS Shim
|
|
2
|
+
|
|
3
|
+
This directory contains the XCTest/XCUIAutomation shim scaffold used for
|
|
4
|
+
selector-grade iOS automation.
|
|
5
|
+
|
|
6
|
+
The public ZMR API remains the scenario file and JSON-RPC protocol. The shim
|
|
7
|
+
protocol is an internal local transport between the Zig runner and an app-local
|
|
8
|
+
UI test runner.
|
|
9
|
+
|
|
10
|
+
Current status:
|
|
11
|
+
|
|
12
|
+
- Zig-side command and snapshot mapping are covered in `src/ios_shim.zig`.
|
|
13
|
+
- `src/ios.zig` can run a configured shim command with one JSON request on
|
|
14
|
+
stdin and one JSON response on stdout.
|
|
15
|
+
- `scripts/install-ios-shim.sh` writes an app-local `.zmr/ios-shim` command and
|
|
16
|
+
copies the XCTest source files into the app repo for inclusion in a UI test
|
|
17
|
+
target.
|
|
18
|
+
- The generated command caches `xcodebuild build-for-testing` output and runs
|
|
19
|
+
selector commands through `test-without-building`, exchanging per-command
|
|
20
|
+
files under `.zmr/ios-shim-state/`. Set `ZMR_IOS_SHIM_FORCE_REBUILD=1` to
|
|
21
|
+
refresh the cached test bundle, or `ZMR_IOS_SHIM_ONESHOT=1` to force the
|
|
22
|
+
slower one-command XCTest fallback for debugging.
|
|
23
|
+
- The iOS adapter still uses `xcrun simctl` for simulator install, launch,
|
|
24
|
+
terminate, open link, screenshots, and logs. It uses `xcrun devicectl` for
|
|
25
|
+
physical-device lifecycle where Apple exposes a supported local command, and
|
|
26
|
+
uses the XCTest shim for physical-device screenshot artifacts.
|
|
27
|
+
- `.zmr/ensure-ios-shim-target.sh` can create or update the UI test target for
|
|
28
|
+
common Xcode project/workspace layouts through the Ruby `xcodeproj` gem.
|
|
29
|
+
Users can still add the generated Swift files manually when their project
|
|
30
|
+
layout needs custom handling.
|
|
31
|
+
- ZMR uses the shim as a native selector fast path for single-field tap, type,
|
|
32
|
+
erase-text, and wait/assert queries. Compound selectors stay on the portable
|
|
33
|
+
Zig observe-and-match path.
|
|
34
|
+
- Snapshot capture is intentionally bounded to common XCTest element families
|
|
35
|
+
and at most 256 nodes. This keeps traces fast and predictable for large
|
|
36
|
+
React Native, SwiftUI, and UIKit trees while preserving the controls agents
|
|
37
|
+
normally need for follow-up actions.
|
|
38
|
+
|
|
39
|
+
Support target:
|
|
40
|
+
|
|
41
|
+
- iOS simulators for full local artifacts: lifecycle, screenshots, logs,
|
|
42
|
+
selector actions, native selector waits, and bounded snapshots.
|
|
43
|
+
- Physical iOS devices for lifecycle and selector-grade XCTest automation,
|
|
44
|
+
subject to local signing, provisioning, Developer Mode, and Apple
|
|
45
|
+
`devicectl` availability. Screenshots use the XCTest shim; log artifact
|
|
46
|
+
capture remains simulator-first.
|
|
47
|
+
- XCTest/XCUIAutomation snapshots mapped into `UiNode`.
|
|
48
|
+
- Selector actions: tap, type, erase text, hide keyboard, swipe, and home/back
|
|
49
|
+
equivalent navigation.
|
|
50
|
+
- Clear state means best-effort app uninstall by bundle id.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
struct ZMRShimCommand: Decodable {
|
|
5
|
+
let cmd: String
|
|
6
|
+
let selector: String?
|
|
7
|
+
let text: String?
|
|
8
|
+
let x: Int?
|
|
9
|
+
let y: Int?
|
|
10
|
+
let x1: Int?
|
|
11
|
+
let y1: Int?
|
|
12
|
+
let x2: Int?
|
|
13
|
+
let y2: Int?
|
|
14
|
+
let durationMs: UInt?
|
|
15
|
+
let maxChars: UInt?
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
struct ZMRShimBounds: Encodable {
|
|
19
|
+
let x: Int
|
|
20
|
+
let y: Int
|
|
21
|
+
let width: Int
|
|
22
|
+
let height: Int
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
struct ZMRShimNode: Encodable {
|
|
26
|
+
let id: String
|
|
27
|
+
let type: String
|
|
28
|
+
let label: String
|
|
29
|
+
let value: String
|
|
30
|
+
let identifier: String
|
|
31
|
+
let bounds: ZMRShimBounds
|
|
32
|
+
let enabled: Bool
|
|
33
|
+
let visible: Bool
|
|
34
|
+
let selected: Bool
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
enum ZMRShim {
|
|
38
|
+
static func snapshot(app: XCUIApplication) -> [ZMRShimNode] {
|
|
39
|
+
let queries: [(XCUIElement.ElementType, XCUIElementQuery)] = [
|
|
40
|
+
(.button, app.buttons),
|
|
41
|
+
(.staticText, app.staticTexts),
|
|
42
|
+
(.textField, app.textFields),
|
|
43
|
+
(.secureTextField, app.secureTextFields),
|
|
44
|
+
(.textView, app.textViews),
|
|
45
|
+
(.image, app.images),
|
|
46
|
+
(.switch, app.switches),
|
|
47
|
+
(.cell, app.cells),
|
|
48
|
+
(.scrollView, app.scrollViews),
|
|
49
|
+
(.table, app.tables),
|
|
50
|
+
(.collectionView, app.collectionViews)
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
var nodes: [ZMRShimNode] = []
|
|
54
|
+
nodes.reserveCapacity(128)
|
|
55
|
+
|
|
56
|
+
for (type, query) in queries {
|
|
57
|
+
for element in query.allElementsBoundByIndex {
|
|
58
|
+
guard nodes.count < 256 else {
|
|
59
|
+
return nodes
|
|
60
|
+
}
|
|
61
|
+
nodes.append(node(index: nodes.count, type: type, element: element))
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if nodes.isEmpty {
|
|
66
|
+
nodes.append(node(index: 0, type: .application, element: app))
|
|
67
|
+
}
|
|
68
|
+
return nodes
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private static func node(index: Int, type: XCUIElement.ElementType, element: XCUIElement) -> ZMRShimNode {
|
|
72
|
+
let frame = element.frame
|
|
73
|
+
return ZMRShimNode(
|
|
74
|
+
id: stableId(index: index, element: element),
|
|
75
|
+
type: String(describing: type),
|
|
76
|
+
label: element.label,
|
|
77
|
+
value: elementValue(element),
|
|
78
|
+
identifier: element.identifier,
|
|
79
|
+
bounds: ZMRShimBounds(
|
|
80
|
+
x: Int(frame.origin.x),
|
|
81
|
+
y: Int(frame.origin.y),
|
|
82
|
+
width: Int(frame.size.width),
|
|
83
|
+
height: Int(frame.size.height)
|
|
84
|
+
),
|
|
85
|
+
enabled: element.isEnabled,
|
|
86
|
+
visible: element.exists && !frame.isEmpty,
|
|
87
|
+
selected: element.isSelected
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private static func elementValue(_ element: XCUIElement) -> String {
|
|
92
|
+
if let value = element.value as? String {
|
|
93
|
+
return value
|
|
94
|
+
}
|
|
95
|
+
if let value = element.value {
|
|
96
|
+
return String(describing: value)
|
|
97
|
+
}
|
|
98
|
+
return ""
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private static func stableId(index: Int, element: XCUIElement) -> String {
|
|
102
|
+
if !element.identifier.isEmpty {
|
|
103
|
+
return "id:\(element.identifier)"
|
|
104
|
+
}
|
|
105
|
+
if !element.label.isEmpty {
|
|
106
|
+
return "label:\(element.label):\(index)"
|
|
107
|
+
}
|
|
108
|
+
return "index:\(index)"
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
final class ZMRShimUITestCase: XCTestCase {
|
|
5
|
+
func testRunZMRCommand() throws {
|
|
6
|
+
let environment = ProcessInfo.processInfo.environment
|
|
7
|
+
let app = makeApplication(bundleIdentifier: shimRuntimeValue("ZMR_APP_BUNDLE_ID", environment: environment))
|
|
8
|
+
|
|
9
|
+
if shimRuntimeValue("ZMR_SHIM_MODE", environment: environment) == "server" {
|
|
10
|
+
guard let serverDir = shimRuntimeValue("ZMR_SHIM_SERVER_DIR", environment: environment) else {
|
|
11
|
+
throw ZMRShimError.missingEnvironment
|
|
12
|
+
}
|
|
13
|
+
try runServer(serverDir: serverDir, app: app)
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
guard let requestFile = shimRuntimeValue("ZMR_SHIM_REQUEST_FILE", environment: environment),
|
|
18
|
+
let responseFile = shimRuntimeValue("ZMR_SHIM_RESPONSE_FILE", environment: environment) else {
|
|
19
|
+
throw ZMRShimError.missingEnvironment
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try process(requestAt: requestFile, responseAt: responseFile, app: app)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private func runServer(serverDir: String, app: XCUIApplication) throws {
|
|
26
|
+
let fileManager = FileManager.default
|
|
27
|
+
try fileManager.createDirectory(atPath: serverDir, withIntermediateDirectories: true)
|
|
28
|
+
|
|
29
|
+
let readyFile = path(in: serverDir, named: "ready")
|
|
30
|
+
let stopFile = path(in: serverDir, named: "stop")
|
|
31
|
+
_ = fileManager.createFile(atPath: readyFile, contents: Data(), attributes: nil)
|
|
32
|
+
|
|
33
|
+
var idleDeadline = Date().addingTimeInterval(900)
|
|
34
|
+
while Date() < idleDeadline {
|
|
35
|
+
if fileManager.fileExists(atPath: stopFile) {
|
|
36
|
+
break
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let requestNames = try fileManager.contentsOfDirectory(atPath: serverDir)
|
|
40
|
+
.filter { $0.hasPrefix("request-") && $0.hasSuffix(".json") }
|
|
41
|
+
.sorted()
|
|
42
|
+
|
|
43
|
+
if requestNames.isEmpty {
|
|
44
|
+
Thread.sleep(forTimeInterval: 0.05)
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for requestName in requestNames {
|
|
49
|
+
let requestID = requestName
|
|
50
|
+
.dropFirst("request-".count)
|
|
51
|
+
.dropLast(".json".count)
|
|
52
|
+
let requestFile = path(in: serverDir, named: requestName)
|
|
53
|
+
let responseFile = path(in: serverDir, named: "response-\(requestID).json")
|
|
54
|
+
|
|
55
|
+
try process(requestAt: requestFile, responseAt: responseFile, app: app)
|
|
56
|
+
try? fileManager.removeItem(atPath: requestFile)
|
|
57
|
+
idleDeadline = Date().addingTimeInterval(900)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private func process(requestAt requestFile: String, responseAt responseFile: String, app: XCUIApplication) throws {
|
|
63
|
+
let response = responseFor(requestAt: requestFile, app: app)
|
|
64
|
+
let responseData = try JSONSerialization.data(withJSONObject: response, options: [.sortedKeys])
|
|
65
|
+
let responseURL = URL(fileURLWithPath: responseFile)
|
|
66
|
+
let temporaryURL = URL(fileURLWithPath: "\(responseFile).tmp")
|
|
67
|
+
try responseData.write(to: temporaryURL, options: [.atomic])
|
|
68
|
+
if FileManager.default.fileExists(atPath: responseFile) {
|
|
69
|
+
try FileManager.default.removeItem(at: responseURL)
|
|
70
|
+
}
|
|
71
|
+
try FileManager.default.moveItem(at: temporaryURL, to: responseURL)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private func responseFor(requestAt requestFile: String, app: XCUIApplication) -> [String: Any] {
|
|
75
|
+
do {
|
|
76
|
+
let requestData = try Data(contentsOf: URL(fileURLWithPath: requestFile))
|
|
77
|
+
let command = try JSONDecoder().decode(ZMRShimCommand.self, from: requestData)
|
|
78
|
+
return run(command: command, app: app)
|
|
79
|
+
} catch {
|
|
80
|
+
return self.error("invalid.request", "\(error)")
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private func path(in directory: String, named name: String) -> String {
|
|
85
|
+
(directory as NSString).appendingPathComponent(name)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private func shimRuntimeValue(_ key: String, environment: [String: String]) -> String? {
|
|
89
|
+
if let value = environment[key], !value.isEmpty, !value.hasPrefix("$(") {
|
|
90
|
+
return value
|
|
91
|
+
}
|
|
92
|
+
if let value = Bundle(for: Self.self).object(forInfoDictionaryKey: key) as? String,
|
|
93
|
+
!value.isEmpty,
|
|
94
|
+
!value.hasPrefix("$(") {
|
|
95
|
+
return value
|
|
96
|
+
}
|
|
97
|
+
return nil
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private func makeApplication(bundleIdentifier: String?) -> XCUIApplication {
|
|
101
|
+
if let bundleIdentifier, !bundleIdentifier.isEmpty {
|
|
102
|
+
return XCUIApplication(bundleIdentifier: bundleIdentifier)
|
|
103
|
+
}
|
|
104
|
+
return XCUIApplication()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private func run(command: ZMRShimCommand, app: XCUIApplication) -> [String: Any] {
|
|
108
|
+
switch command.cmd {
|
|
109
|
+
case "snapshot":
|
|
110
|
+
return [
|
|
111
|
+
"status": "ok",
|
|
112
|
+
"nodes": ZMRShim.snapshot(app: app).map { $0.json }
|
|
113
|
+
]
|
|
114
|
+
case "screenshot":
|
|
115
|
+
let screenshot = XCUIScreen.main.screenshot()
|
|
116
|
+
return [
|
|
117
|
+
"status": "ok",
|
|
118
|
+
"format": "png",
|
|
119
|
+
"base64": screenshot.pngRepresentation.base64EncodedString()
|
|
120
|
+
]
|
|
121
|
+
case "query":
|
|
122
|
+
guard let selector = command.selector else {
|
|
123
|
+
return error("invalid.query", "query requires selector")
|
|
124
|
+
}
|
|
125
|
+
guard let parts = selectorParts(selector) else {
|
|
126
|
+
return error("selector.unsupported", "unsupported selector: \(selector)")
|
|
127
|
+
}
|
|
128
|
+
guard isFastQueryable(parts: parts) else {
|
|
129
|
+
return error("selector.unsupported", "unsupported query selector: \(selector)")
|
|
130
|
+
}
|
|
131
|
+
let element = resolveFastElement(selector: selector, app: app, preferredTypes: [])
|
|
132
|
+
return [
|
|
133
|
+
"status": "ok",
|
|
134
|
+
"exists": element?.exists ?? false,
|
|
135
|
+
"hittable": element?.isHittable ?? false
|
|
136
|
+
]
|
|
137
|
+
case "tap":
|
|
138
|
+
if let selector = command.selector {
|
|
139
|
+
return tap(selector: selector, app: app)
|
|
140
|
+
}
|
|
141
|
+
guard let x = command.x, let y = command.y else {
|
|
142
|
+
return error("invalid.tap", "tap requires x and y")
|
|
143
|
+
}
|
|
144
|
+
app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
|
|
145
|
+
.withOffset(CGVector(dx: x, dy: y))
|
|
146
|
+
.tap()
|
|
147
|
+
return ok()
|
|
148
|
+
case "type":
|
|
149
|
+
if let selector = command.selector {
|
|
150
|
+
return typeText(selector: selector, text: command.text ?? "", app: app)
|
|
151
|
+
}
|
|
152
|
+
app.typeText(command.text ?? "")
|
|
153
|
+
return ok()
|
|
154
|
+
case "eraseText":
|
|
155
|
+
let count = Int(command.maxChars ?? 0)
|
|
156
|
+
if let selector = command.selector {
|
|
157
|
+
return eraseText(selector: selector, count: count, app: app)
|
|
158
|
+
}
|
|
159
|
+
if count > 0 {
|
|
160
|
+
app.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: count))
|
|
161
|
+
}
|
|
162
|
+
return ok()
|
|
163
|
+
case "hideKeyboard":
|
|
164
|
+
if app.keyboards.firstMatch.exists {
|
|
165
|
+
app.keyboards.buttons["Return"].tap()
|
|
166
|
+
}
|
|
167
|
+
return ok()
|
|
168
|
+
case "swipe":
|
|
169
|
+
guard let x1 = command.x1, let y1 = command.y1, let x2 = command.x2, let y2 = command.y2 else {
|
|
170
|
+
return error("invalid.swipe", "swipe requires x1, y1, x2, and y2")
|
|
171
|
+
}
|
|
172
|
+
let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
|
|
173
|
+
.withOffset(CGVector(dx: x1, dy: y1))
|
|
174
|
+
let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
|
|
175
|
+
.withOffset(CGVector(dx: x2, dy: y2))
|
|
176
|
+
start.press(forDuration: 0.01, thenDragTo: end)
|
|
177
|
+
return ok()
|
|
178
|
+
case "pressBack":
|
|
179
|
+
XCUIDevice.shared.press(.home)
|
|
180
|
+
return ok()
|
|
181
|
+
case "settle":
|
|
182
|
+
let timeout = TimeInterval(command.durationMs ?? 1000) / 1000.0
|
|
183
|
+
_ = app.wait(for: app.state, timeout: timeout)
|
|
184
|
+
return ok()
|
|
185
|
+
case "appState":
|
|
186
|
+
return ["status": "ok", "state": app.state.rawValue]
|
|
187
|
+
case "acceptSystemAlert":
|
|
188
|
+
return acceptSystemAlert(buttonText: command.text ?? "Open")
|
|
189
|
+
default:
|
|
190
|
+
return error("unknown.command", "unsupported command: \(command.cmd)")
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private func ok() -> [String: Any] {
|
|
195
|
+
["status": "ok"]
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private func error(_ code: String, _ message: String) -> [String: Any] {
|
|
199
|
+
["status": "error", "code": code, "message": message]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private func acceptSystemAlert(buttonText: String) -> [String: Any] {
|
|
203
|
+
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
|
204
|
+
var labels = [buttonText, "Open", "Allow", "OK", "Continue"]
|
|
205
|
+
labels = labels.reduce(into: [String]()) { unique, label in
|
|
206
|
+
if !label.isEmpty && !unique.contains(label) {
|
|
207
|
+
unique.append(label)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
var acceptedCount = 0
|
|
212
|
+
var lastAcceptedLabel = ""
|
|
213
|
+
for _ in 0..<3 {
|
|
214
|
+
var tapped = false
|
|
215
|
+
for label in labels {
|
|
216
|
+
let button = springboard.buttons[label].firstMatch
|
|
217
|
+
if button.waitForExistence(timeout: 2), button.isHittable {
|
|
218
|
+
button.tap()
|
|
219
|
+
acceptedCount += 1
|
|
220
|
+
lastAcceptedLabel = label
|
|
221
|
+
tapped = true
|
|
222
|
+
Thread.sleep(forTimeInterval: 1.0)
|
|
223
|
+
break
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if !tapped {
|
|
227
|
+
break
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if acceptedCount > 0 {
|
|
232
|
+
return ["status": "ok", "accepted": true, "label": lastAcceptedLabel, "count": acceptedCount]
|
|
233
|
+
}
|
|
234
|
+
return ["status": "ok", "accepted": false, "count": 0]
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private func tap(selector: String, app: XCUIApplication) -> [String: Any] {
|
|
238
|
+
guard selectorParts(selector) != nil else {
|
|
239
|
+
return error("selector.unsupported", "unsupported selector: \(selector)")
|
|
240
|
+
}
|
|
241
|
+
guard let element = resolveElement(selector: selector, app: app, preferredTypes: [.button]) else {
|
|
242
|
+
return error("selector.not_found", "selector did not match: \(selector)")
|
|
243
|
+
}
|
|
244
|
+
if !element.isHittable {
|
|
245
|
+
return error("selector.not_hittable", "selector matched a non-hittable element: \(selector)")
|
|
246
|
+
}
|
|
247
|
+
element.tap()
|
|
248
|
+
return ok()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private func typeText(selector: String, text: String, app: XCUIApplication) -> [String: Any] {
|
|
252
|
+
guard selectorParts(selector) != nil else {
|
|
253
|
+
return error("selector.unsupported", "unsupported selector: \(selector)")
|
|
254
|
+
}
|
|
255
|
+
guard let element = resolveElement(selector: selector, app: app, preferredTypes: [.textField, .secureTextField, .textView]) else {
|
|
256
|
+
return error("selector.not_found", "selector did not match: \(selector)")
|
|
257
|
+
}
|
|
258
|
+
if !element.isHittable {
|
|
259
|
+
return error("selector.not_hittable", "selector matched a non-hittable element: \(selector)")
|
|
260
|
+
}
|
|
261
|
+
element.tap()
|
|
262
|
+
app.typeText(text)
|
|
263
|
+
return ok()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private func eraseText(selector: String, count: Int, app: XCUIApplication) -> [String: Any] {
|
|
267
|
+
guard selectorParts(selector) != nil else {
|
|
268
|
+
return error("selector.unsupported", "unsupported selector: \(selector)")
|
|
269
|
+
}
|
|
270
|
+
guard let element = resolveElement(selector: selector, app: app, preferredTypes: [.textField, .secureTextField, .textView]) else {
|
|
271
|
+
return error("selector.not_found", "selector did not match: \(selector)")
|
|
272
|
+
}
|
|
273
|
+
if !element.isHittable {
|
|
274
|
+
return error("selector.not_hittable", "selector matched a non-hittable element: \(selector)")
|
|
275
|
+
}
|
|
276
|
+
element.tap()
|
|
277
|
+
if count > 0 {
|
|
278
|
+
app.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: count))
|
|
279
|
+
}
|
|
280
|
+
return ok()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private func resolveElement(selector: String, app: XCUIApplication, preferredTypes: [XCUIElement.ElementType] = []) -> XCUIElement? {
|
|
284
|
+
if let fast = resolveFastElement(selector: selector, app: app, preferredTypes: preferredTypes), fast.exists {
|
|
285
|
+
return fast
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let matchedElements = app.descendants(matching: .any).allElementsBoundByIndex.filter { element in
|
|
289
|
+
matches(selector: selector, element: element)
|
|
290
|
+
}
|
|
291
|
+
if let preferred = matchedElements.first(where: { preferredTypes.contains($0.elementType) && $0.isHittable }) {
|
|
292
|
+
return preferred
|
|
293
|
+
}
|
|
294
|
+
if let preferred = matchedElements.first(where: { preferredTypes.contains($0.elementType) }) {
|
|
295
|
+
return preferred
|
|
296
|
+
}
|
|
297
|
+
if let hittable = matchedElements.first(where: { $0.isHittable }) {
|
|
298
|
+
return hittable
|
|
299
|
+
}
|
|
300
|
+
return matchedElements.first
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private func resolveFastElement(selector: String, app: XCUIApplication, preferredTypes: [XCUIElement.ElementType]) -> XCUIElement? {
|
|
304
|
+
guard let parts = selectorParts(selector) else {
|
|
305
|
+
return nil
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
switch parts.field {
|
|
309
|
+
case "text", "label":
|
|
310
|
+
let queries = fastTextQueries(app: app, preferredTypes: preferredTypes)
|
|
311
|
+
if parts.contains {
|
|
312
|
+
let predicate = NSPredicate(format: "label CONTAINS[c] %@", parts.value)
|
|
313
|
+
return firstExistingElement(queries: queries.map { $0.matching(predicate) })
|
|
314
|
+
}
|
|
315
|
+
let predicate = NSPredicate(format: "label == %@", parts.value)
|
|
316
|
+
return firstExistingElement(queries: queries.map { $0.matching(predicate) })
|
|
317
|
+
case "identifier", "resourceId":
|
|
318
|
+
let queries = fastIdentifierQueries(app: app, preferredTypes: preferredTypes, contains: parts.contains)
|
|
319
|
+
if parts.contains {
|
|
320
|
+
let predicate = NSPredicate(format: "identifier CONTAINS[c] %@", parts.value)
|
|
321
|
+
return firstExistingElement(queries: queries.map { $0.matching(predicate) })
|
|
322
|
+
}
|
|
323
|
+
return firstExistingElement(queries: queries.map { $0.matching(identifier: parts.value) })
|
|
324
|
+
case "value":
|
|
325
|
+
let queries = fastTextQueries(app: app, preferredTypes: preferredTypes)
|
|
326
|
+
let predicate = parts.contains
|
|
327
|
+
? NSPredicate(format: "value CONTAINS[c] %@", parts.value)
|
|
328
|
+
: NSPredicate(format: "value == %@", parts.value)
|
|
329
|
+
return firstExistingElement(queries: queries.map { $0.matching(predicate) })
|
|
330
|
+
case "type", "id":
|
|
331
|
+
return nil
|
|
332
|
+
default:
|
|
333
|
+
return nil
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private func fastTextQueries(app: XCUIApplication, preferredTypes: [XCUIElement.ElementType]) -> [XCUIElementQuery] {
|
|
338
|
+
var queries: [XCUIElementQuery] = []
|
|
339
|
+
if !preferredTypes.isEmpty {
|
|
340
|
+
queries.append(contentsOf: preferredTypes.map { app.descendants(matching: $0) })
|
|
341
|
+
}
|
|
342
|
+
queries.append(contentsOf: [
|
|
343
|
+
app.buttons,
|
|
344
|
+
app.staticTexts,
|
|
345
|
+
app.textFields,
|
|
346
|
+
app.secureTextFields,
|
|
347
|
+
app.textViews,
|
|
348
|
+
app.images
|
|
349
|
+
])
|
|
350
|
+
return queries
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private func fastIdentifierQueries(
|
|
354
|
+
app: XCUIApplication,
|
|
355
|
+
preferredTypes: [XCUIElement.ElementType],
|
|
356
|
+
contains: Bool
|
|
357
|
+
) -> [XCUIElementQuery] {
|
|
358
|
+
var queries = fastTextQueries(app: app, preferredTypes: preferredTypes)
|
|
359
|
+
if !contains {
|
|
360
|
+
queries.append(app.otherElements)
|
|
361
|
+
}
|
|
362
|
+
return queries
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private func firstExistingElement(queries: [XCUIElementQuery]) -> XCUIElement? {
|
|
366
|
+
for query in queries {
|
|
367
|
+
let element = query.firstMatch
|
|
368
|
+
if element.exists {
|
|
369
|
+
return element
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return nil
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private func isFastQueryable(parts: (field: String, value: String, contains: Bool)) -> Bool {
|
|
376
|
+
switch parts.field {
|
|
377
|
+
case "text", "label", "identifier", "resourceId", "value":
|
|
378
|
+
return true
|
|
379
|
+
default:
|
|
380
|
+
return false
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private func matches(selector: String, element: XCUIElement) -> Bool {
|
|
385
|
+
guard element.exists, let parts = selectorParts(selector) else {
|
|
386
|
+
return false
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let actual: String
|
|
390
|
+
switch parts.field {
|
|
391
|
+
case "text", "label":
|
|
392
|
+
actual = element.label
|
|
393
|
+
case "identifier", "resourceId":
|
|
394
|
+
actual = element.identifier
|
|
395
|
+
case "id":
|
|
396
|
+
actual = stableId(element: element)
|
|
397
|
+
case "value":
|
|
398
|
+
actual = element.value as? String ?? ""
|
|
399
|
+
case "type":
|
|
400
|
+
actual = String(describing: element.elementType)
|
|
401
|
+
default:
|
|
402
|
+
return false
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if parts.contains {
|
|
406
|
+
return actual.localizedCaseInsensitiveContains(parts.value)
|
|
407
|
+
}
|
|
408
|
+
return actual == parts.value
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private func selectorParts(_ selector: String) -> (field: String, value: String, contains: Bool)? {
|
|
412
|
+
let supportedPrefixes = [
|
|
413
|
+
("textContains=", "text", true),
|
|
414
|
+
("labelContains=", "label", true),
|
|
415
|
+
("identifierContains=", "identifier", true),
|
|
416
|
+
("resourceIdContains=", "resourceId", true),
|
|
417
|
+
("valueContains=", "value", true),
|
|
418
|
+
("type=", "type", false),
|
|
419
|
+
("text=", "text", false),
|
|
420
|
+
("label=", "label", false),
|
|
421
|
+
("identifier=", "identifier", false),
|
|
422
|
+
("resourceId=", "resourceId", false),
|
|
423
|
+
("id=", "id", false),
|
|
424
|
+
("value=", "value", false)
|
|
425
|
+
]
|
|
426
|
+
|
|
427
|
+
for (prefix, field, contains) in supportedPrefixes {
|
|
428
|
+
if selector.hasPrefix(prefix) {
|
|
429
|
+
let value = String(selector.dropFirst(prefix.count))
|
|
430
|
+
return value.isEmpty ? nil : (field, value, contains)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return nil
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private func stableId(element: XCUIElement) -> String {
|
|
437
|
+
if !element.identifier.isEmpty {
|
|
438
|
+
return "id:\(element.identifier)"
|
|
439
|
+
}
|
|
440
|
+
if !element.label.isEmpty {
|
|
441
|
+
return "label:\(element.label)"
|
|
442
|
+
}
|
|
443
|
+
return String(describing: element.elementType)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
enum ZMRShimError: Error {
|
|
448
|
+
case missingEnvironment
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private extension ZMRShimBounds {
|
|
452
|
+
var json: [String: Any] {
|
|
453
|
+
[
|
|
454
|
+
"x": x,
|
|
455
|
+
"y": y,
|
|
456
|
+
"width": width,
|
|
457
|
+
"height": height
|
|
458
|
+
]
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private extension ZMRShimNode {
|
|
463
|
+
var json: [String: Any] {
|
|
464
|
+
[
|
|
465
|
+
"id": id,
|
|
466
|
+
"type": type,
|
|
467
|
+
"label": label,
|
|
468
|
+
"identifier": identifier,
|
|
469
|
+
"bounds": bounds.json,
|
|
470
|
+
"enabled": enabled,
|
|
471
|
+
"visible": visible,
|
|
472
|
+
"selected": selected
|
|
473
|
+
]
|
|
474
|
+
}
|
|
475
|
+
}
|