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,432 @@
|
|
|
1
|
+
package zmr
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bufio"
|
|
5
|
+
"context"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"errors"
|
|
8
|
+
"fmt"
|
|
9
|
+
"io"
|
|
10
|
+
"os/exec"
|
|
11
|
+
"sync"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
type Client struct {
|
|
15
|
+
cmd *exec.Cmd
|
|
16
|
+
stdin io.WriteCloser
|
|
17
|
+
lines *bufio.Scanner
|
|
18
|
+
nextID int64
|
|
19
|
+
mu sync.Mutex
|
|
20
|
+
closed bool
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type RPCError struct {
|
|
24
|
+
Code int `json:"code"`
|
|
25
|
+
Message string `json:"message"`
|
|
26
|
+
PublicCode string `json:"publicCode,omitempty"`
|
|
27
|
+
Data json.RawMessage `json:"data,omitempty"`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func (e *RPCError) Error() string {
|
|
31
|
+
if e.Message == "" {
|
|
32
|
+
return "ZMR JSON-RPC error"
|
|
33
|
+
}
|
|
34
|
+
return e.Message
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type rpcRequest struct {
|
|
38
|
+
JSONRPC string `json:"jsonrpc"`
|
|
39
|
+
ID int64 `json:"id"`
|
|
40
|
+
Method string `json:"method"`
|
|
41
|
+
Params interface{} `json:"params"`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type rpcResponse struct {
|
|
45
|
+
JSONRPC string `json:"jsonrpc"`
|
|
46
|
+
ID int64 `json:"id"`
|
|
47
|
+
Result json.RawMessage `json:"result,omitempty"`
|
|
48
|
+
Error *RPCError `json:"error,omitempty"`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type Capabilities struct {
|
|
52
|
+
Name string `json:"name"`
|
|
53
|
+
Version string `json:"version"`
|
|
54
|
+
ProtocolVersion string `json:"protocolVersion"`
|
|
55
|
+
Platforms []string `json:"platforms"`
|
|
56
|
+
PlatformSupport map[string]PlatformSupport `json:"platformSupport"`
|
|
57
|
+
IosPreview bool `json:"iosPreview"`
|
|
58
|
+
Transports []string `json:"transports"`
|
|
59
|
+
Methods []string `json:"methods"`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type PlatformSupport struct {
|
|
63
|
+
Status string `json:"status"`
|
|
64
|
+
DeviceTypes []string `json:"deviceTypes"`
|
|
65
|
+
Automation []string `json:"automation"`
|
|
66
|
+
PhysicalDevices bool `json:"physicalDevices,omitempty"`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type Session struct {
|
|
70
|
+
SessionID string `json:"sessionId"`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type DeviceInfo struct {
|
|
74
|
+
Serial string `json:"serial"`
|
|
75
|
+
State string `json:"state"`
|
|
76
|
+
Ready bool `json:"ready"`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type Snapshot struct {
|
|
80
|
+
ID string `json:"id"`
|
|
81
|
+
TimestampMS int64 `json:"timestampMs"`
|
|
82
|
+
Viewport map[string]interface{} `json:"viewport"`
|
|
83
|
+
ActivePackage string `json:"activePackage"`
|
|
84
|
+
ActiveActivity string `json:"activeActivity"`
|
|
85
|
+
Nodes []Node `json:"nodes"`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type Node struct {
|
|
89
|
+
StableID string `json:"stableId"`
|
|
90
|
+
ClassName string `json:"className"`
|
|
91
|
+
ResourceID string `json:"resourceId,omitempty"`
|
|
92
|
+
Text *string `json:"text"`
|
|
93
|
+
ContentDesc *string `json:"contentDesc"`
|
|
94
|
+
Bounds map[string]interface{} `json:"bounds"`
|
|
95
|
+
Enabled bool `json:"enabled"`
|
|
96
|
+
Visible bool `json:"visible"`
|
|
97
|
+
Selected bool `json:"selected"`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type SemanticSnapshot struct {
|
|
101
|
+
ID string `json:"id"`
|
|
102
|
+
TimestampMS int64 `json:"timestampMs"`
|
|
103
|
+
Viewport map[string]any `json:"viewport"`
|
|
104
|
+
ActivePackage string `json:"activePackage"`
|
|
105
|
+
ActiveActivity string `json:"activeActivity"`
|
|
106
|
+
FocusedNodeID *string `json:"focusedNodeId"`
|
|
107
|
+
Nodes []SemanticNode `json:"nodes"`
|
|
108
|
+
Summary struct {
|
|
109
|
+
NodeCount int `json:"nodeCount"`
|
|
110
|
+
InteractiveCount int `json:"interactiveCount"`
|
|
111
|
+
VisibleText []string `json:"visibleText"`
|
|
112
|
+
} `json:"summary"`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type SemanticNode struct {
|
|
116
|
+
ID string `json:"id"`
|
|
117
|
+
Role string `json:"role"`
|
|
118
|
+
Name string `json:"name"`
|
|
119
|
+
Selector map[string]string `json:"selector"`
|
|
120
|
+
Source map[string]interface{} `json:"source"`
|
|
121
|
+
Bounds map[string]interface{} `json:"bounds"`
|
|
122
|
+
Enabled bool `json:"enabled"`
|
|
123
|
+
Visible bool `json:"visible"`
|
|
124
|
+
Selected bool `json:"selected"`
|
|
125
|
+
Interactive bool `json:"interactive"`
|
|
126
|
+
RecommendedAction *string `json:"recommendedAction"`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
type TraceEvents struct {
|
|
130
|
+
TraceDir string `json:"traceDir"`
|
|
131
|
+
AfterSeq int64 `json:"afterSeq"`
|
|
132
|
+
NextSeq int64 `json:"nextSeq"`
|
|
133
|
+
LatestSeq int64 `json:"latestSeq"`
|
|
134
|
+
Events []map[string]interface{} `json:"events"`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
type TraceExport struct {
|
|
138
|
+
TraceDir string `json:"traceDir"`
|
|
139
|
+
Out string `json:"out"`
|
|
140
|
+
Redacted bool `json:"redacted"`
|
|
141
|
+
OmitScreenshots bool `json:"omitScreenshots"`
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func Start(ctx context.Context, command string, args ...string) (*Client, error) {
|
|
145
|
+
cmd := exec.CommandContext(ctx, command, args...)
|
|
146
|
+
stdin, err := cmd.StdinPipe()
|
|
147
|
+
if err != nil {
|
|
148
|
+
return nil, err
|
|
149
|
+
}
|
|
150
|
+
stdout, err := cmd.StdoutPipe()
|
|
151
|
+
if err != nil {
|
|
152
|
+
return nil, err
|
|
153
|
+
}
|
|
154
|
+
if err := cmd.Start(); err != nil {
|
|
155
|
+
return nil, err
|
|
156
|
+
}
|
|
157
|
+
scanner := bufio.NewScanner(stdout)
|
|
158
|
+
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
|
159
|
+
return &Client{cmd: cmd, stdin: stdin, lines: scanner, nextID: 1}, nil
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
func (c *Client) Close() error {
|
|
163
|
+
c.mu.Lock()
|
|
164
|
+
if c.closed {
|
|
165
|
+
c.mu.Unlock()
|
|
166
|
+
return nil
|
|
167
|
+
}
|
|
168
|
+
c.closed = true
|
|
169
|
+
stdin := c.stdin
|
|
170
|
+
cmd := c.cmd
|
|
171
|
+
c.mu.Unlock()
|
|
172
|
+
|
|
173
|
+
_ = stdin.Close()
|
|
174
|
+
if cmd.Process != nil {
|
|
175
|
+
_ = cmd.Process.Kill()
|
|
176
|
+
}
|
|
177
|
+
return cmd.Wait()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
func (c *Client) Request(ctx context.Context, method string, params interface{}, out interface{}) error {
|
|
181
|
+
c.mu.Lock()
|
|
182
|
+
defer c.mu.Unlock()
|
|
183
|
+
if c.closed {
|
|
184
|
+
return errors.New("zmr client is closed")
|
|
185
|
+
}
|
|
186
|
+
id := c.nextID
|
|
187
|
+
c.nextID++
|
|
188
|
+
request := rpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params}
|
|
189
|
+
line, err := json.Marshal(request)
|
|
190
|
+
if err != nil {
|
|
191
|
+
return err
|
|
192
|
+
}
|
|
193
|
+
if _, err := c.stdin.Write(append(line, '\n')); err != nil {
|
|
194
|
+
return err
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
type responseResult struct {
|
|
198
|
+
line string
|
|
199
|
+
ok bool
|
|
200
|
+
}
|
|
201
|
+
responseCh := make(chan responseResult, 1)
|
|
202
|
+
go func() {
|
|
203
|
+
ok := c.lines.Scan()
|
|
204
|
+
responseCh <- responseResult{line: c.lines.Text(), ok: ok}
|
|
205
|
+
}()
|
|
206
|
+
|
|
207
|
+
select {
|
|
208
|
+
case <-ctx.Done():
|
|
209
|
+
return ctx.Err()
|
|
210
|
+
case response := <-responseCh:
|
|
211
|
+
if !response.ok {
|
|
212
|
+
if err := c.lines.Err(); err != nil {
|
|
213
|
+
return err
|
|
214
|
+
}
|
|
215
|
+
return io.EOF
|
|
216
|
+
}
|
|
217
|
+
var decoded rpcResponse
|
|
218
|
+
if err := json.Unmarshal([]byte(response.line), &decoded); err != nil {
|
|
219
|
+
return err
|
|
220
|
+
}
|
|
221
|
+
if decoded.ID != id {
|
|
222
|
+
return fmt.Errorf("unexpected JSON-RPC response id %d", decoded.ID)
|
|
223
|
+
}
|
|
224
|
+
if decoded.Error != nil {
|
|
225
|
+
return decoded.Error
|
|
226
|
+
}
|
|
227
|
+
if out == nil {
|
|
228
|
+
return nil
|
|
229
|
+
}
|
|
230
|
+
return json.Unmarshal(decoded.Result, out)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
func (c *Client) Capabilities(ctx context.Context) (Capabilities, error) {
|
|
235
|
+
var out Capabilities
|
|
236
|
+
err := c.Request(ctx, "runner.capabilities", map[string]interface{}{}, &out)
|
|
237
|
+
return out, err
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
func (c *Client) CreateSession(ctx context.Context) (Session, error) {
|
|
241
|
+
var out Session
|
|
242
|
+
err := c.Request(ctx, "session.create", map[string]interface{}{}, &out)
|
|
243
|
+
return out, err
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
func (c *Client) CloseSession(ctx context.Context) (bool, error) {
|
|
247
|
+
var out bool
|
|
248
|
+
err := c.Request(ctx, "session.close", map[string]interface{}{}, &out)
|
|
249
|
+
return out, err
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
func (c *Client) Devices(ctx context.Context) ([]DeviceInfo, error) {
|
|
253
|
+
var out []DeviceInfo
|
|
254
|
+
err := c.Request(ctx, "device.list", map[string]interface{}{}, &out)
|
|
255
|
+
return out, err
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
func (c *Client) Launch(ctx context.Context) (bool, error) {
|
|
259
|
+
var out bool
|
|
260
|
+
err := c.Request(ctx, "app.launch", map[string]interface{}{}, &out)
|
|
261
|
+
return out, err
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
func (c *Client) Stop(ctx context.Context) (bool, error) {
|
|
265
|
+
var out bool
|
|
266
|
+
err := c.Request(ctx, "app.stop", map[string]interface{}{}, &out)
|
|
267
|
+
return out, err
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
func (c *Client) ClearState(ctx context.Context) (bool, error) {
|
|
271
|
+
var out bool
|
|
272
|
+
err := c.Request(ctx, "app.clearState", map[string]interface{}{}, &out)
|
|
273
|
+
return out, err
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
func (c *Client) OpenLink(ctx context.Context, url string) (bool, error) {
|
|
277
|
+
var out bool
|
|
278
|
+
err := c.Request(ctx, "app.openLink", map[string]interface{}{"url": url}, &out)
|
|
279
|
+
return out, err
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
func (c *Client) Snapshot(ctx context.Context) (Snapshot, error) {
|
|
283
|
+
var out Snapshot
|
|
284
|
+
err := c.Request(ctx, "observe.snapshot", map[string]interface{}{}, &out)
|
|
285
|
+
return out, err
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
func (c *Client) SemanticSnapshot(ctx context.Context) (SemanticSnapshot, error) {
|
|
289
|
+
var out SemanticSnapshot
|
|
290
|
+
err := c.Request(ctx, "observe.semanticSnapshot", map[string]interface{}{}, &out)
|
|
291
|
+
return out, err
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
func (c *Client) Tap(ctx context.Context, selector map[string]interface{}) (bool, error) {
|
|
295
|
+
var out bool
|
|
296
|
+
err := c.Request(ctx, "ui.tap", map[string]interface{}{"selector": selector}, &out)
|
|
297
|
+
return out, err
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
func (c *Client) TypeText(ctx context.Context, text string, selector map[string]interface{}) (bool, error) {
|
|
301
|
+
var out bool
|
|
302
|
+
params := map[string]interface{}{"text": text}
|
|
303
|
+
if selector != nil {
|
|
304
|
+
params["selector"] = selector
|
|
305
|
+
}
|
|
306
|
+
err := c.Request(ctx, "ui.type", params, &out)
|
|
307
|
+
return out, err
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
func (c *Client) EraseText(ctx context.Context, selector map[string]interface{}, maxChars int64) (bool, error) {
|
|
311
|
+
var out bool
|
|
312
|
+
params := map[string]interface{}{}
|
|
313
|
+
if selector != nil {
|
|
314
|
+
params["selector"] = selector
|
|
315
|
+
}
|
|
316
|
+
if maxChars > 0 {
|
|
317
|
+
params["maxChars"] = maxChars
|
|
318
|
+
}
|
|
319
|
+
err := c.Request(ctx, "ui.eraseText", params, &out)
|
|
320
|
+
return out, err
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
func (c *Client) HideKeyboard(ctx context.Context) (bool, error) {
|
|
324
|
+
var out bool
|
|
325
|
+
err := c.Request(ctx, "ui.hideKeyboard", map[string]interface{}{}, &out)
|
|
326
|
+
return out, err
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
func (c *Client) Swipe(ctx context.Context, x1 int64, y1 int64, x2 int64, y2 int64, durationMS int64) (bool, error) {
|
|
330
|
+
var out bool
|
|
331
|
+
params := map[string]interface{}{"x1": x1, "y1": y1, "x2": x2, "y2": y2}
|
|
332
|
+
if durationMS > 0 {
|
|
333
|
+
params["durationMs"] = durationMS
|
|
334
|
+
}
|
|
335
|
+
err := c.Request(ctx, "ui.swipe", params, &out)
|
|
336
|
+
return out, err
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
func (c *Client) PressBack(ctx context.Context) (bool, error) {
|
|
340
|
+
var out bool
|
|
341
|
+
err := c.Request(ctx, "ui.pressBack", map[string]interface{}{}, &out)
|
|
342
|
+
return out, err
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
func (c *Client) ScrollUntilVisible(ctx context.Context, selector map[string]interface{}, direction string, timeoutMS int64) (bool, error) {
|
|
346
|
+
var out bool
|
|
347
|
+
params := map[string]interface{}{"selector": selector}
|
|
348
|
+
if direction != "" {
|
|
349
|
+
params["direction"] = direction
|
|
350
|
+
}
|
|
351
|
+
if timeoutMS > 0 {
|
|
352
|
+
params["timeoutMs"] = timeoutMS
|
|
353
|
+
}
|
|
354
|
+
err := c.Request(ctx, "ui.scrollUntilVisible", params, &out)
|
|
355
|
+
return out, err
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
func (c *Client) WaitUntil(ctx context.Context, selector map[string]interface{}, timeoutMS int64) (bool, error) {
|
|
359
|
+
var out bool
|
|
360
|
+
params := map[string]interface{}{"visible": selector}
|
|
361
|
+
if timeoutMS > 0 {
|
|
362
|
+
params["timeoutMs"] = timeoutMS
|
|
363
|
+
}
|
|
364
|
+
err := c.Request(ctx, "wait.until", params, &out)
|
|
365
|
+
return out, err
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
func (c *Client) WaitAny(ctx context.Context, selectors []map[string]interface{}, timeoutMS int64) (bool, error) {
|
|
369
|
+
var out bool
|
|
370
|
+
params := map[string]interface{}{"selectors": selectors}
|
|
371
|
+
if timeoutMS > 0 {
|
|
372
|
+
params["timeoutMs"] = timeoutMS
|
|
373
|
+
}
|
|
374
|
+
err := c.Request(ctx, "wait.any", params, &out)
|
|
375
|
+
return out, err
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
func (c *Client) WaitGone(ctx context.Context, selector map[string]interface{}, timeoutMS int64) (bool, error) {
|
|
379
|
+
var out bool
|
|
380
|
+
params := map[string]interface{}{"selector": selector}
|
|
381
|
+
if timeoutMS > 0 {
|
|
382
|
+
params["timeoutMs"] = timeoutMS
|
|
383
|
+
}
|
|
384
|
+
err := c.Request(ctx, "wait.gone", params, &out)
|
|
385
|
+
return out, err
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
func (c *Client) AssertVisible(ctx context.Context, selector map[string]interface{}, timeoutMS int64) (bool, error) {
|
|
389
|
+
var out bool
|
|
390
|
+
params := map[string]interface{}{"selector": selector}
|
|
391
|
+
if timeoutMS > 0 {
|
|
392
|
+
params["timeoutMs"] = timeoutMS
|
|
393
|
+
}
|
|
394
|
+
err := c.Request(ctx, "assert.visible", params, &out)
|
|
395
|
+
return out, err
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
func (c *Client) AssertNotVisible(ctx context.Context, selector map[string]interface{}, timeoutMS int64) (bool, error) {
|
|
399
|
+
var out bool
|
|
400
|
+
params := map[string]interface{}{"selector": selector}
|
|
401
|
+
if timeoutMS > 0 {
|
|
402
|
+
params["timeoutMs"] = timeoutMS
|
|
403
|
+
}
|
|
404
|
+
err := c.Request(ctx, "assert.notVisible", params, &out)
|
|
405
|
+
return out, err
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
func (c *Client) AssertHealthy(ctx context.Context, timeoutMS int64) (bool, error) {
|
|
409
|
+
var out bool
|
|
410
|
+
params := map[string]interface{}{}
|
|
411
|
+
if timeoutMS > 0 {
|
|
412
|
+
params["timeoutMs"] = timeoutMS
|
|
413
|
+
}
|
|
414
|
+
err := c.Request(ctx, "assert.healthy", params, &out)
|
|
415
|
+
return out, err
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
func (c *Client) ExportTrace(ctx context.Context, outPath string, redact bool, omitScreenshots bool) (TraceExport, error) {
|
|
419
|
+
var out TraceExport
|
|
420
|
+
err := c.Request(ctx, "trace.export", map[string]interface{}{"out": outPath, "redact": redact, "omitScreenshots": omitScreenshots}, &out)
|
|
421
|
+
return out, err
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
func (c *Client) TraceEvents(ctx context.Context, afterSeq int64, limit int64) (TraceEvents, error) {
|
|
425
|
+
var out TraceEvents
|
|
426
|
+
params := map[string]interface{}{"afterSeq": afterSeq}
|
|
427
|
+
if limit > 0 {
|
|
428
|
+
params["limit"] = limit
|
|
429
|
+
}
|
|
430
|
+
err := c.Request(ctx, "trace.events", params, &out)
|
|
431
|
+
return out, err
|
|
432
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# ZMR Kotlin Client
|
|
2
|
+
|
|
3
|
+
Small JVM client for Kotlin agents and test harnesses that drive
|
|
4
|
+
`zmr serve --transport stdio`.
|
|
5
|
+
|
|
6
|
+
For now, build it from a local checkout and consume the generated jar:
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
git submodule add https://github.com/johnmikel/zig-mobile-runner.git vendor/zig-mobile-runner
|
|
10
|
+
gradle -p vendor/zig-mobile-runner/clients/kotlin build
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Run the package test from the repository root:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
gradle -p clients/kotlin test
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Run the fake-session example from the repository root:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
gradle -p clients/kotlin runFakeSession \
|
|
23
|
+
-Pzmr="$PWD/zig-out/bin/zmr" \
|
|
24
|
+
-Padb="$PWD/tests/fake-adb.sh" \
|
|
25
|
+
-PtraceDir="$PWD/traces/demo-kotlin-client" \
|
|
26
|
+
-PtraceOut="$PWD/traces/demo-kotlin-client-redacted.zmrtrace"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```kotlin
|
|
30
|
+
implementation(files("path/to/zig-mobile-runner/clients/kotlin/build/libs/zmr-client-0.1.0.jar"))
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The Kotlin client is host-side. It is useful for Android teams that want test
|
|
34
|
+
or agent tooling in Kotlin, but it still controls the app through the local
|
|
35
|
+
`zmr` binary rather than running inside the app process.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
plugins {
|
|
2
|
+
kotlin("jvm") version "2.0.21"
|
|
3
|
+
`java-library`
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
group = "dev.zmr"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
|
|
9
|
+
kotlin {
|
|
10
|
+
jvmToolchain(17)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
dependencies {
|
|
14
|
+
testImplementation(kotlin("test"))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
tasks.test {
|
|
18
|
+
useJUnitPlatform()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
tasks.register<JavaExec>("runFakeSession") {
|
|
22
|
+
group = "application"
|
|
23
|
+
description = "Run the fake ZMR JSON-RPC session example."
|
|
24
|
+
dependsOn(tasks.named("classes"))
|
|
25
|
+
classpath = sourceSets["main"].runtimeClasspath
|
|
26
|
+
mainClass.set("dev.zmr.FakeSessionKt")
|
|
27
|
+
args = listOf(
|
|
28
|
+
"--zmr", providers.gradleProperty("zmr").orElse("zig-out/bin/zmr").get(),
|
|
29
|
+
"--adb", providers.gradleProperty("adb").orElse("tests/fake-adb.sh").get(),
|
|
30
|
+
"--device", providers.gradleProperty("device").orElse("fake-android-1").get(),
|
|
31
|
+
"--app-id", providers.gradleProperty("appId").orElse("com.example.mobiletest").get(),
|
|
32
|
+
"--trace-dir", providers.gradleProperty("traceDir").orElse("traces/demo-kotlin-client").get(),
|
|
33
|
+
"--trace-out", providers.gradleProperty("traceOut").orElse("traces/demo-kotlin-client-redacted.zmrtrace").get()
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
pluginManagement {
|
|
2
|
+
repositories {
|
|
3
|
+
mavenCentral()
|
|
4
|
+
gradlePluginPortal()
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
dependencyResolutionManagement {
|
|
9
|
+
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
|
10
|
+
repositories {
|
|
11
|
+
mavenCentral()
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
rootProject.name = "zmr-client"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
package dev.zmr
|
|
2
|
+
|
|
3
|
+
private data class Options(
|
|
4
|
+
var zmr: String = "zig-out/bin/zmr",
|
|
5
|
+
var adb: String = "tests/fake-adb.sh",
|
|
6
|
+
var device: String = "fake-android-1",
|
|
7
|
+
var appId: String = "com.example.mobiletest",
|
|
8
|
+
var traceDir: String = "traces/demo-kotlin-client",
|
|
9
|
+
var traceOut: String = "traces/demo-kotlin-client-redacted.zmrtrace"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
fun main(args: Array<String>) {
|
|
13
|
+
val options = parseOptions(args)
|
|
14
|
+
ZmrClient(
|
|
15
|
+
listOf(
|
|
16
|
+
options.zmr,
|
|
17
|
+
"serve",
|
|
18
|
+
"--transport", "stdio",
|
|
19
|
+
"--device", options.device,
|
|
20
|
+
"--app-id", options.appId,
|
|
21
|
+
"--adb", options.adb,
|
|
22
|
+
"--trace-dir", options.traceDir
|
|
23
|
+
)
|
|
24
|
+
).use { client ->
|
|
25
|
+
val capabilities = client.call("runner.capabilities")
|
|
26
|
+
client.createSession()
|
|
27
|
+
client.call("app.openLink", """{"url":"exampleapp://kotlin-client"}""")
|
|
28
|
+
client.call("wait.until", """{"visible":{"text":"Dashboard"},"timeoutMs":1000}""")
|
|
29
|
+
client.call("ui.tap", """{"selector":{"text":"Sign in"}}""")
|
|
30
|
+
client.call("ui.type", """{"text":"agent@example.com","selector":{"resourceId":"email-login-email-input"}}""")
|
|
31
|
+
client.call("assert.notVisible", """{"selector":{"text":"Application has crashed"},"timeoutMs":100}""")
|
|
32
|
+
client.assertHealthy(timeoutMs = 100)
|
|
33
|
+
val snapshot = client.snapshot()
|
|
34
|
+
val exported = client.call(
|
|
35
|
+
"trace.export",
|
|
36
|
+
"""{"out":"${escapeJson(options.traceOut)}","redact":true,"includeScreenshots":true}"""
|
|
37
|
+
)
|
|
38
|
+
val events = client.call("trace.events", """{"afterSeq":0,"limit":10}""")
|
|
39
|
+
|
|
40
|
+
println(
|
|
41
|
+
"{" +
|
|
42
|
+
"\"protocolVersion\":\"${extractString(capabilities, "protocolVersion")}\"," +
|
|
43
|
+
"\"activePackage\":\"${extractString(snapshot, "activePackage")}\"," +
|
|
44
|
+
"\"nodes\":${countOccurrences(snapshot, "\"stableId\"")}," +
|
|
45
|
+
"\"events\":${extractNumber(events, "nextSeq")}," +
|
|
46
|
+
"\"traceDir\":\"${escapeJson(options.traceDir)}\"," +
|
|
47
|
+
"\"traceOut\":\"${escapeJson(extractString(exported, "out"))}\"" +
|
|
48
|
+
"}"
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private fun parseOptions(args: Array<String>): Options {
|
|
54
|
+
val options = Options()
|
|
55
|
+
var index = 0
|
|
56
|
+
while (index < args.size) {
|
|
57
|
+
val value = args.getOrNull(index + 1) ?: error("${args[index]} requires a value")
|
|
58
|
+
when (args[index]) {
|
|
59
|
+
"--zmr" -> options.zmr = value
|
|
60
|
+
"--adb" -> options.adb = value
|
|
61
|
+
"--device" -> options.device = value
|
|
62
|
+
"--app-id" -> options.appId = value
|
|
63
|
+
"--trace-dir" -> options.traceDir = value
|
|
64
|
+
"--trace-out" -> options.traceOut = value
|
|
65
|
+
else -> error("unknown argument: ${args[index]}")
|
|
66
|
+
}
|
|
67
|
+
index += 2
|
|
68
|
+
}
|
|
69
|
+
return options
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private fun extractString(json: String, key: String): String {
|
|
73
|
+
val match = Regex(""""$key"\s*:\s*"([^"]*)"""").find(json)
|
|
74
|
+
return match?.groupValues?.get(1) ?: ""
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private fun extractNumber(json: String, key: String): String {
|
|
78
|
+
val match = Regex(""""$key"\s*:\s*([0-9]+)""").find(json)
|
|
79
|
+
return match?.groupValues?.get(1) ?: "0"
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private fun countOccurrences(value: String, needle: String): Int =
|
|
83
|
+
Regex.escape(needle).toRegex().findAll(value).count()
|
|
84
|
+
|
|
85
|
+
private fun escapeJson(value: String): String =
|
|
86
|
+
value.replace("\\", "\\\\").replace("\"", "\\\"")
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
package dev.zmr
|
|
2
|
+
|
|
3
|
+
import java.io.BufferedReader
|
|
4
|
+
import java.io.BufferedWriter
|
|
5
|
+
import java.io.Closeable
|
|
6
|
+
import java.io.InputStreamReader
|
|
7
|
+
import java.io.OutputStreamWriter
|
|
8
|
+
|
|
9
|
+
class ZmrRpcException(
|
|
10
|
+
val code: Int,
|
|
11
|
+
message: String,
|
|
12
|
+
val publicCode: String? = null
|
|
13
|
+
) : RuntimeException(message)
|
|
14
|
+
|
|
15
|
+
class ZmrClient(
|
|
16
|
+
private val command: List<String> = listOf("zmr", "serve", "--transport", "stdio")
|
|
17
|
+
) : Closeable {
|
|
18
|
+
private var nextId = 1
|
|
19
|
+
private val process = ProcessBuilder(command).redirectError(ProcessBuilder.Redirect.INHERIT).start()
|
|
20
|
+
private val input = BufferedWriter(OutputStreamWriter(process.outputStream))
|
|
21
|
+
private val output = BufferedReader(InputStreamReader(process.inputStream))
|
|
22
|
+
|
|
23
|
+
fun createSession(): String = call("session.create")
|
|
24
|
+
|
|
25
|
+
fun snapshot(): String = call("observe.snapshot")
|
|
26
|
+
|
|
27
|
+
fun semanticSnapshot(): String = call("observe.semanticSnapshot")
|
|
28
|
+
|
|
29
|
+
fun assertHealthy(timeoutMs: Long? = null): String {
|
|
30
|
+
val params = timeoutMs?.let { "{\"timeoutMs\":$it}" } ?: "{}"
|
|
31
|
+
return call("assert.healthy", params)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Synchronized
|
|
35
|
+
fun call(method: String, paramsJson: String? = null): String {
|
|
36
|
+
val id = nextId++
|
|
37
|
+
val params = paramsJson?.let { "," + "\"params\":" + it } ?: ""
|
|
38
|
+
input.write("{\"jsonrpc\":\"2.0\",\"id\":$id,\"method\":\"$method\"$params}")
|
|
39
|
+
input.newLine()
|
|
40
|
+
input.flush()
|
|
41
|
+
val response = output.readLine() ?: error("zmr closed stdout")
|
|
42
|
+
if (response.contains(""""error"""")) {
|
|
43
|
+
throw ZmrRpcException(
|
|
44
|
+
code = extractNumber(response, "code") ?: -32000,
|
|
45
|
+
message = extractString(response, "message").ifEmpty { "ZMR JSON-RPC error" },
|
|
46
|
+
publicCode = extractString(response, "publicCode").ifEmpty { null }
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
return response
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
override fun close() {
|
|
53
|
+
runCatching { call("session.close") }
|
|
54
|
+
runCatching { input.close() }
|
|
55
|
+
process.destroy()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private fun extractString(json: String, key: String): String {
|
|
60
|
+
val pattern = """"$key"\s*:\s*"([^"]*)"""".toRegex()
|
|
61
|
+
return pattern.find(json)?.groupValues?.get(1) ?: ""
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private fun extractNumber(json: String, key: String): Int? {
|
|
65
|
+
val pattern = """"$key"\s*:\s*(-?[0-9]+)""".toRegex()
|
|
66
|
+
return pattern.find(json)?.groupValues?.get(1)?.toIntOrNull()
|
|
67
|
+
}
|