@qubiqlabs/mobiflow 0.9.0 → 1.0.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/README.md +9 -11
- package/bin/mobiflow.js +94 -61
- package/package.json +8 -3
- package/pyproject.toml +59 -0
- package/src/mobiflow/__init__.py +9 -0
- package/src/mobiflow/__main__.py +6 -0
- package/src/mobiflow/baseline.py +228 -0
- package/src/mobiflow/casedata.py +159 -0
- package/src/mobiflow/cases/__init__.py +715 -0
- package/src/mobiflow/cli.py +1423 -0
- package/src/mobiflow/cloud/__init__.py +28 -0
- package/src/mobiflow/cloud/base.py +272 -0
- package/src/mobiflow/cloud/browserstack.py +330 -0
- package/src/mobiflow/cloud/maestro_cloud.py +141 -0
- package/src/mobiflow/cloud/media.py +269 -0
- package/src/mobiflow/cloud/runner.py +156 -0
- package/src/mobiflow/cloud/testmu.py +378 -0
- package/src/mobiflow/config/__init__.py +538 -0
- package/src/mobiflow/deps.py +377 -0
- package/src/mobiflow/devices.py +717 -0
- package/src/mobiflow/explore.py +623 -0
- package/src/mobiflow/incremental.py +198 -0
- package/src/mobiflow/init/__init__.py +794 -0
- package/src/mobiflow/llm.py +462 -0
- package/src/mobiflow/llm_catalog.py +232 -0
- package/src/mobiflow/maestro/__init__.py +1506 -0
- package/src/mobiflow/maestro/lifecycle.py +279 -0
- package/src/mobiflow/pipeline.py +600 -0
- package/src/mobiflow/report/__init__.py +617 -0
- package/src/mobiflow/report/static/favicon.jpg +0 -0
- package/src/mobiflow/report/static/favicon.svg +1 -0
- package/src/mobiflow/report/static/icons.svg +24 -0
- package/src/mobiflow/report/static/index.html +99 -0
- package/src/mobiflow/report/static/mobiflow-mark.jpg +0 -0
- package/src/mobiflow/reporting.py +682 -0
- package/src/mobiflow/sample_apps.py +259 -0
- package/src/mobiflow/secrets.py +90 -0
- package/src/mobiflow/selectors.py +128 -0
- package/src/mobiflow/suite.py +263 -0
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
"""Discover and auto-start Android emulators / iOS simulators (macOS + Windows).
|
|
2
|
+
|
|
3
|
+
- Android: ``adb`` online devices + ``emulator -list-avds``; start via emulator binary
|
|
4
|
+
- iOS: ``xcrun simctl`` (macOS only); boot + open Simulator.app
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Callable, Optional
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
ProgressFn = Optional[Callable[[str], None]]
|
|
22
|
+
IS_MAC = platform.system() == "Darwin"
|
|
23
|
+
IS_WIN = platform.system() == "Windows"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _sdk_roots() -> list[Path]:
|
|
27
|
+
roots: list[Path] = []
|
|
28
|
+
for key in ("ANDROID_HOME", "ANDROID_SDK_ROOT"):
|
|
29
|
+
val = os.environ.get(key)
|
|
30
|
+
if val:
|
|
31
|
+
roots.append(Path(val))
|
|
32
|
+
home = Path.home()
|
|
33
|
+
if IS_MAC:
|
|
34
|
+
roots.append(home / "Library/Android/sdk")
|
|
35
|
+
if IS_WIN:
|
|
36
|
+
local = os.environ.get("LOCALAPPDATA") or str(home / "AppData/Local")
|
|
37
|
+
roots.append(Path(local) / "Android" / "Sdk")
|
|
38
|
+
roots.append(home / "AppData/Local/Android/Sdk")
|
|
39
|
+
# Linux common
|
|
40
|
+
roots.append(home / "Android/Sdk")
|
|
41
|
+
# Dedupe
|
|
42
|
+
seen: set[str] = set()
|
|
43
|
+
out: list[Path] = []
|
|
44
|
+
for r in roots:
|
|
45
|
+
key = str(r.resolve()) if r.exists() else str(r)
|
|
46
|
+
if key not in seen:
|
|
47
|
+
seen.add(key)
|
|
48
|
+
out.append(r)
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_adb() -> Optional[str]:
|
|
53
|
+
which = shutil.which("adb")
|
|
54
|
+
if which:
|
|
55
|
+
return which
|
|
56
|
+
exe = "adb.exe" if IS_WIN else "adb"
|
|
57
|
+
for root in _sdk_roots():
|
|
58
|
+
candidate = root / "platform-tools" / exe
|
|
59
|
+
if candidate.is_file():
|
|
60
|
+
return str(candidate)
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def resolve_emulator() -> Optional[str]:
|
|
65
|
+
which = shutil.which("emulator")
|
|
66
|
+
if which:
|
|
67
|
+
return which
|
|
68
|
+
exe = "emulator.exe" if IS_WIN else "emulator"
|
|
69
|
+
for root in _sdk_roots():
|
|
70
|
+
for sub in ("emulator", "tools"):
|
|
71
|
+
candidate = root / sub / exe
|
|
72
|
+
if candidate.is_file():
|
|
73
|
+
return str(candidate)
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def _run_cmd(
|
|
78
|
+
args: list[str],
|
|
79
|
+
*,
|
|
80
|
+
timeout: float = 60.0,
|
|
81
|
+
cwd: Optional[str] = None,
|
|
82
|
+
) -> dict[str, Any]:
|
|
83
|
+
try:
|
|
84
|
+
proc = await asyncio.create_subprocess_exec(
|
|
85
|
+
*args,
|
|
86
|
+
stdout=asyncio.subprocess.PIPE,
|
|
87
|
+
stderr=asyncio.subprocess.PIPE,
|
|
88
|
+
cwd=cwd,
|
|
89
|
+
env=os.environ.copy(),
|
|
90
|
+
)
|
|
91
|
+
except FileNotFoundError as e:
|
|
92
|
+
return {
|
|
93
|
+
"ok": False,
|
|
94
|
+
"returncode": -1,
|
|
95
|
+
"stdout": "",
|
|
96
|
+
"stderr": str(e),
|
|
97
|
+
"error": "executable_not_found",
|
|
98
|
+
}
|
|
99
|
+
try:
|
|
100
|
+
stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
101
|
+
except asyncio.TimeoutError:
|
|
102
|
+
try:
|
|
103
|
+
proc.kill()
|
|
104
|
+
except ProcessLookupError:
|
|
105
|
+
pass
|
|
106
|
+
return {
|
|
107
|
+
"ok": False,
|
|
108
|
+
"returncode": -1,
|
|
109
|
+
"stdout": "",
|
|
110
|
+
"stderr": f"Timed out after {timeout}s",
|
|
111
|
+
"error": "timeout",
|
|
112
|
+
}
|
|
113
|
+
stdout = (stdout_b or b"").decode("utf-8", errors="replace")
|
|
114
|
+
stderr = (stderr_b or b"").decode("utf-8", errors="replace")
|
|
115
|
+
code = proc.returncode if proc.returncode is not None else -1
|
|
116
|
+
return {
|
|
117
|
+
"ok": code == 0,
|
|
118
|
+
"returncode": code,
|
|
119
|
+
"stdout": stdout,
|
|
120
|
+
"stderr": stderr,
|
|
121
|
+
"error": None if code == 0 else "nonzero_exit",
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def match_connected_device(
|
|
126
|
+
device_id: str,
|
|
127
|
+
connected: list[dict[str, str]],
|
|
128
|
+
*,
|
|
129
|
+
avd_by_serial: dict[str, str] | None = None,
|
|
130
|
+
) -> dict[str, str] | None:
|
|
131
|
+
"""Match adb serial, case-insensitive id, or running AVD name.
|
|
132
|
+
|
|
133
|
+
Android Studio shows AVD names (``Pixel_6``); ``adb devices`` shows
|
|
134
|
+
``emulator-5554``. Config often has one while Maestro needs the other.
|
|
135
|
+
"""
|
|
136
|
+
want = (device_id or "").strip()
|
|
137
|
+
if not want:
|
|
138
|
+
return None
|
|
139
|
+
want_l = want.lower().replace(" ", "_")
|
|
140
|
+
for d in connected:
|
|
141
|
+
did = str(d.get("id") or "")
|
|
142
|
+
name = str(d.get("name") or "")
|
|
143
|
+
if did == want or did.lower() == want.lower():
|
|
144
|
+
return d
|
|
145
|
+
if name.lower().replace(" ", "_") == want_l:
|
|
146
|
+
return d
|
|
147
|
+
for serial, avd_name in (avd_by_serial or {}).items():
|
|
148
|
+
if str(avd_name).strip().lower().replace(" ", "_") == want_l:
|
|
149
|
+
match = next((d for d in connected if d.get("id") == serial), None)
|
|
150
|
+
if match:
|
|
151
|
+
return match
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _parse_adb_devices(stdout: str) -> list[dict[str, str]]:
|
|
156
|
+
devices: list[dict[str, str]] = []
|
|
157
|
+
for line in (stdout or "").splitlines():
|
|
158
|
+
line = line.strip()
|
|
159
|
+
if not line or line.startswith("List of devices"):
|
|
160
|
+
continue
|
|
161
|
+
parts = line.split()
|
|
162
|
+
if len(parts) >= 2 and parts[1] == "device":
|
|
163
|
+
serial = parts[0]
|
|
164
|
+
kind = "emulator" if serial.startswith("emulator-") else "device"
|
|
165
|
+
devices.append(
|
|
166
|
+
{
|
|
167
|
+
"id": serial,
|
|
168
|
+
"platform": "android",
|
|
169
|
+
"name": serial,
|
|
170
|
+
"state": "online",
|
|
171
|
+
"kind": kind,
|
|
172
|
+
"source": "adb",
|
|
173
|
+
"startable": "false",
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
return devices
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def _online_avd_names() -> dict[str, str]:
|
|
180
|
+
"""Map emulator serial → AVD name for running emulators."""
|
|
181
|
+
adb = resolve_adb()
|
|
182
|
+
if not adb:
|
|
183
|
+
return {}
|
|
184
|
+
out: dict[str, str] = {}
|
|
185
|
+
for d in await list_android_online():
|
|
186
|
+
serial = d.get("id") or ""
|
|
187
|
+
if not serial.startswith("emulator-"):
|
|
188
|
+
continue
|
|
189
|
+
r = await _run_cmd(
|
|
190
|
+
[adb, "-s", serial, "emu", "avd", "name"],
|
|
191
|
+
timeout=10.0,
|
|
192
|
+
)
|
|
193
|
+
text = (r.get("stdout") or "") + "\n" + (r.get("stderr") or "")
|
|
194
|
+
for line in text.splitlines():
|
|
195
|
+
line = line.strip()
|
|
196
|
+
if line and line.upper() != "OK" and not line.lower().startswith("error"):
|
|
197
|
+
out[serial] = line
|
|
198
|
+
break
|
|
199
|
+
return out
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def list_android_online() -> list[dict[str, str]]:
|
|
203
|
+
adb = resolve_adb()
|
|
204
|
+
if not adb:
|
|
205
|
+
return []
|
|
206
|
+
result = await _run_cmd([adb, "devices"], timeout=15.0)
|
|
207
|
+
return _parse_adb_devices(result.get("stdout") or "")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def list_android_avds() -> list[dict[str, str]]:
|
|
211
|
+
"""AVDs that can be started (may already be running)."""
|
|
212
|
+
emu = resolve_emulator()
|
|
213
|
+
if not emu:
|
|
214
|
+
return []
|
|
215
|
+
result = await _run_cmd([emu, "-list-avds"], timeout=30.0)
|
|
216
|
+
if not result.get("ok") and not (result.get("stdout") or "").strip():
|
|
217
|
+
return []
|
|
218
|
+
online = {d["id"] for d in await list_android_online()}
|
|
219
|
+
# Heuristic: if any emulator-* online, treat first AVD match as running later
|
|
220
|
+
avds: list[dict[str, str]] = []
|
|
221
|
+
for line in (result.get("stdout") or "").splitlines():
|
|
222
|
+
name = line.strip()
|
|
223
|
+
if not name:
|
|
224
|
+
continue
|
|
225
|
+
# We can't map AVD name → emulator-5554 reliably without adb emu avd name;
|
|
226
|
+
# mark as available; online check refined in ensure_device.
|
|
227
|
+
avds.append(
|
|
228
|
+
{
|
|
229
|
+
"id": name,
|
|
230
|
+
"platform": "android",
|
|
231
|
+
"name": name,
|
|
232
|
+
"state": "available",
|
|
233
|
+
"kind": "avd",
|
|
234
|
+
"source": "emulator",
|
|
235
|
+
"startable": "true",
|
|
236
|
+
}
|
|
237
|
+
)
|
|
238
|
+
# Enrich: query running emulators for avd name
|
|
239
|
+
adb = resolve_adb()
|
|
240
|
+
running_avds: set[str] = set()
|
|
241
|
+
if adb:
|
|
242
|
+
for d in await list_android_online():
|
|
243
|
+
if not d["id"].startswith("emulator-"):
|
|
244
|
+
continue
|
|
245
|
+
r = await _run_cmd(
|
|
246
|
+
[adb, "-s", d["id"], "emu", "avd", "name"],
|
|
247
|
+
timeout=10.0,
|
|
248
|
+
)
|
|
249
|
+
# Output is "Pixel_6\nOK\n" or similar
|
|
250
|
+
text = (r.get("stdout") or "") + "\n" + (r.get("stderr") or "")
|
|
251
|
+
for line in text.splitlines():
|
|
252
|
+
line = line.strip()
|
|
253
|
+
if line and line.upper() != "OK" and not line.startswith("error"):
|
|
254
|
+
running_avds.add(line)
|
|
255
|
+
break
|
|
256
|
+
for avd in avds:
|
|
257
|
+
if avd["name"] in running_avds:
|
|
258
|
+
avd["state"] = "online"
|
|
259
|
+
avd["startable"] = "false"
|
|
260
|
+
del online # reserved
|
|
261
|
+
return avds
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _parse_simctl_all(stdout: str) -> list[dict[str, str]]:
|
|
265
|
+
"""Parse `simctl list devices available` including Booted and Shutdown."""
|
|
266
|
+
devices: list[dict[str, str]] = []
|
|
267
|
+
current_runtime = ""
|
|
268
|
+
for line in (stdout or "").splitlines():
|
|
269
|
+
rt = re.match(r"^--\s+(.+?)\s+--$", line.strip())
|
|
270
|
+
if rt:
|
|
271
|
+
current_runtime = rt.group(1).strip()
|
|
272
|
+
continue
|
|
273
|
+
m = re.search(
|
|
274
|
+
r"^\s+(.+?)\s+\(([0-9A-Fa-f-]{36})\)\s+\((Booted|Shutdown|Creating)\)",
|
|
275
|
+
line,
|
|
276
|
+
)
|
|
277
|
+
if not m:
|
|
278
|
+
continue
|
|
279
|
+
name, udid, state = m.group(1).strip(), m.group(2), m.group(3)
|
|
280
|
+
if state == "Creating":
|
|
281
|
+
continue
|
|
282
|
+
# Skip unavailable markers in name
|
|
283
|
+
if "unavailable" in name.lower():
|
|
284
|
+
continue
|
|
285
|
+
devices.append(
|
|
286
|
+
{
|
|
287
|
+
"id": udid,
|
|
288
|
+
"platform": "ios",
|
|
289
|
+
"name": name,
|
|
290
|
+
"state": "online" if state == "Booted" else "available",
|
|
291
|
+
"kind": "simulator",
|
|
292
|
+
"source": "simctl",
|
|
293
|
+
"startable": "false" if state == "Booted" else "true",
|
|
294
|
+
"runtime": current_runtime,
|
|
295
|
+
}
|
|
296
|
+
)
|
|
297
|
+
return devices
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
async def list_ios_simulators(*, include_shutdown: bool = True) -> list[dict[str, str]]:
|
|
301
|
+
if not IS_MAC or not shutil.which("xcrun"):
|
|
302
|
+
return []
|
|
303
|
+
result = await _run_cmd(
|
|
304
|
+
["xcrun", "simctl", "list", "devices", "available"],
|
|
305
|
+
timeout=25.0,
|
|
306
|
+
)
|
|
307
|
+
devices = _parse_simctl_all(result.get("stdout") or "")
|
|
308
|
+
if not include_shutdown:
|
|
309
|
+
devices = [d for d in devices if d.get("state") == "online"]
|
|
310
|
+
return devices
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
async def list_connected_devices() -> list[dict[str, str]]:
|
|
314
|
+
"""Devices currently usable by Maestro (online adb + booted sims)."""
|
|
315
|
+
devices = await list_android_online()
|
|
316
|
+
if IS_MAC:
|
|
317
|
+
devices.extend(
|
|
318
|
+
[d for d in await list_ios_simulators(include_shutdown=False)]
|
|
319
|
+
)
|
|
320
|
+
return devices
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
async def list_all_targets() -> list[dict[str, str]]:
|
|
324
|
+
"""Online devices + startable AVDs / iOS simulators."""
|
|
325
|
+
connected = await list_connected_devices()
|
|
326
|
+
by_id = {d["id"]: d for d in connected}
|
|
327
|
+
# Add AVDs not already represented
|
|
328
|
+
for avd in await list_android_avds():
|
|
329
|
+
if avd["state"] == "online":
|
|
330
|
+
# Prefer adb serial entries; keep AVD as informational if no serial map
|
|
331
|
+
continue
|
|
332
|
+
if avd["id"] not in by_id:
|
|
333
|
+
by_id[avd["id"]] = avd
|
|
334
|
+
if IS_MAC:
|
|
335
|
+
for sim in await list_ios_simulators(include_shutdown=True):
|
|
336
|
+
by_id[sim["id"]] = sim # booted overwrites with richer state
|
|
337
|
+
return list(by_id.values())
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
async def _wait_android_online(
|
|
341
|
+
*,
|
|
342
|
+
timeout_s: float = 120.0,
|
|
343
|
+
progress: ProgressFn = None,
|
|
344
|
+
) -> Optional[dict[str, str]]:
|
|
345
|
+
adb = resolve_adb()
|
|
346
|
+
if not adb:
|
|
347
|
+
return None
|
|
348
|
+
deadline = time.monotonic() + timeout_s
|
|
349
|
+
while time.monotonic() < deadline:
|
|
350
|
+
# adb wait-for-device is per-default-device; poll instead
|
|
351
|
+
online = await list_android_online()
|
|
352
|
+
emus = [d for d in online if d["id"].startswith("emulator-")]
|
|
353
|
+
if emus:
|
|
354
|
+
# Wait until boot completed
|
|
355
|
+
serial = emus[0]["id"]
|
|
356
|
+
boot = await _run_cmd(
|
|
357
|
+
[adb, "-s", serial, "shell", "getprop", "sys.boot_completed"],
|
|
358
|
+
timeout=15.0,
|
|
359
|
+
)
|
|
360
|
+
val = (boot.get("stdout") or "").strip()
|
|
361
|
+
if val == "1":
|
|
362
|
+
if progress:
|
|
363
|
+
progress(f"Android emulator ready: {serial}")
|
|
364
|
+
return emus[0]
|
|
365
|
+
await asyncio.sleep(2.0)
|
|
366
|
+
if progress:
|
|
367
|
+
progress("Waiting for Android emulator to finish booting…")
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
async def start_android_avd(
|
|
372
|
+
avd_name: str,
|
|
373
|
+
*,
|
|
374
|
+
timeout_s: float = 120.0,
|
|
375
|
+
progress: ProgressFn = None,
|
|
376
|
+
) -> dict[str, Any]:
|
|
377
|
+
emu = resolve_emulator()
|
|
378
|
+
if not emu:
|
|
379
|
+
return {
|
|
380
|
+
"ok": False,
|
|
381
|
+
"error": "emulator_not_found",
|
|
382
|
+
"message": "Android emulator binary not found. Install Android Studio / SDK.",
|
|
383
|
+
}
|
|
384
|
+
if progress:
|
|
385
|
+
progress(f"Starting Android AVD: {avd_name}")
|
|
386
|
+
# Launch detached so it keeps running
|
|
387
|
+
try:
|
|
388
|
+
kwargs: dict[str, Any] = {
|
|
389
|
+
"stdout": asyncio.subprocess.DEVNULL,
|
|
390
|
+
"stderr": asyncio.subprocess.DEVNULL,
|
|
391
|
+
"env": os.environ.copy(),
|
|
392
|
+
}
|
|
393
|
+
if IS_WIN:
|
|
394
|
+
# Don't inherit console; detach
|
|
395
|
+
kwargs["creationflags"] = getattr(subprocess_mod(), "DETACHED_PROCESS", 0) | getattr(
|
|
396
|
+
subprocess_mod(), "CREATE_NEW_PROCESS_GROUP", 0
|
|
397
|
+
)
|
|
398
|
+
else:
|
|
399
|
+
kwargs["start_new_session"] = True
|
|
400
|
+
await asyncio.create_subprocess_exec(
|
|
401
|
+
emu,
|
|
402
|
+
"-avd",
|
|
403
|
+
avd_name,
|
|
404
|
+
"-netdelay",
|
|
405
|
+
"none",
|
|
406
|
+
"-netspeed",
|
|
407
|
+
"full",
|
|
408
|
+
**kwargs,
|
|
409
|
+
)
|
|
410
|
+
except Exception as e: # noqa: BLE001
|
|
411
|
+
return {"ok": False, "error": "start_failed", "message": str(e)}
|
|
412
|
+
|
|
413
|
+
device = await _wait_android_online(timeout_s=timeout_s, progress=progress)
|
|
414
|
+
if not device:
|
|
415
|
+
return {
|
|
416
|
+
"ok": False,
|
|
417
|
+
"error": "boot_timeout",
|
|
418
|
+
"message": f"AVD {avd_name} started but did not become ready in {timeout_s}s",
|
|
419
|
+
}
|
|
420
|
+
return {"ok": True, "device": device, "avd": avd_name}
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def subprocess_mod():
|
|
424
|
+
import subprocess
|
|
425
|
+
|
|
426
|
+
return subprocess
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
async def start_ios_simulator(
|
|
430
|
+
udid: str,
|
|
431
|
+
*,
|
|
432
|
+
timeout_s: float = 90.0,
|
|
433
|
+
progress: ProgressFn = None,
|
|
434
|
+
) -> dict[str, Any]:
|
|
435
|
+
if not IS_MAC:
|
|
436
|
+
return {
|
|
437
|
+
"ok": False,
|
|
438
|
+
"error": "ios_mac_only",
|
|
439
|
+
"message": "iOS Simulator can only be started on macOS (Xcode).",
|
|
440
|
+
}
|
|
441
|
+
if progress:
|
|
442
|
+
progress(f"Booting iOS Simulator: {udid}")
|
|
443
|
+
boot = await _run_cmd(["xcrun", "simctl", "boot", udid], timeout=60.0)
|
|
444
|
+
# Already booted is OK
|
|
445
|
+
err = (boot.get("stderr") or "") + (boot.get("stdout") or "")
|
|
446
|
+
if not boot.get("ok") and "current state: Booted" not in err and "Already booted" not in err:
|
|
447
|
+
# simctl returns non-zero if already booted on some versions — continue
|
|
448
|
+
if "Booted" not in err and boot.get("returncode") not in (0,):
|
|
449
|
+
# Still try open
|
|
450
|
+
pass
|
|
451
|
+
# Open Simulator.app UI
|
|
452
|
+
await _run_cmd(["open", "-a", "Simulator"], timeout=30.0)
|
|
453
|
+
|
|
454
|
+
deadline = time.monotonic() + timeout_s
|
|
455
|
+
while time.monotonic() < deadline:
|
|
456
|
+
sims = await list_ios_simulators(include_shutdown=False)
|
|
457
|
+
match = next((s for s in sims if s["id"] == udid), None)
|
|
458
|
+
if match:
|
|
459
|
+
if progress:
|
|
460
|
+
progress(f"iOS Simulator ready: {match.get('name')} ({udid})")
|
|
461
|
+
return {"ok": True, "device": match}
|
|
462
|
+
await asyncio.sleep(1.5)
|
|
463
|
+
if progress:
|
|
464
|
+
progress("Waiting for iOS Simulator to boot…")
|
|
465
|
+
return {
|
|
466
|
+
"ok": False,
|
|
467
|
+
"error": "boot_timeout",
|
|
468
|
+
"message": f"Simulator {udid} did not boot in {timeout_s}s",
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
async def _maestro_start_device(
|
|
473
|
+
*,
|
|
474
|
+
platform: str,
|
|
475
|
+
device_model: str = "",
|
|
476
|
+
device_os: str = "",
|
|
477
|
+
device_locale: str = "",
|
|
478
|
+
timeout_s: float = 120.0,
|
|
479
|
+
progress: ProgressFn = None,
|
|
480
|
+
) -> dict[str, Any]:
|
|
481
|
+
"""Best-effort `maestro start-device` then return a newly online device."""
|
|
482
|
+
from mobiflow.maestro import resolve_maestro_binary
|
|
483
|
+
|
|
484
|
+
binary = resolve_maestro_binary()
|
|
485
|
+
if not binary:
|
|
486
|
+
return {"ok": False, "error": "maestro_not_installed"}
|
|
487
|
+
|
|
488
|
+
plat = (platform or "android").lower()
|
|
489
|
+
if plat not in {"android", "ios", "web"}:
|
|
490
|
+
plat = "android"
|
|
491
|
+
|
|
492
|
+
args = [binary, "start-device", "--platform", plat]
|
|
493
|
+
model = (device_model or "").strip()
|
|
494
|
+
# Don't pass raw UDIDs / emulator-XXXX as --device-model
|
|
495
|
+
if model and not re.fullmatch(r"[0-9A-Fa-f-]{36}", model) and not model.startswith(
|
|
496
|
+
"emulator-"
|
|
497
|
+
):
|
|
498
|
+
args.extend(["--device-model", model])
|
|
499
|
+
if (device_os or "").strip():
|
|
500
|
+
args.extend(["--device-os", device_os.strip()])
|
|
501
|
+
if (device_locale or "").strip():
|
|
502
|
+
args.extend(["--device-locale", device_locale.strip()])
|
|
503
|
+
|
|
504
|
+
if progress:
|
|
505
|
+
progress(f"Starting device via Maestro CLI ({plat})…")
|
|
506
|
+
result = await _run_cmd(args, timeout=max(60.0, float(timeout_s)))
|
|
507
|
+
if not result.get("ok"):
|
|
508
|
+
return {
|
|
509
|
+
"ok": False,
|
|
510
|
+
"error": "maestro_start_device_failed",
|
|
511
|
+
"stderr": result.get("stderr") or result.get("stdout") or "",
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
deadline = time.monotonic() + float(timeout_s)
|
|
515
|
+
while time.monotonic() < deadline:
|
|
516
|
+
connected = await list_connected_devices()
|
|
517
|
+
for d in connected:
|
|
518
|
+
if d.get("platform") == plat:
|
|
519
|
+
if progress:
|
|
520
|
+
progress(
|
|
521
|
+
f"Maestro device ready: {d.get('name')} ({d.get('id')})"
|
|
522
|
+
)
|
|
523
|
+
return {
|
|
524
|
+
"ok": True,
|
|
525
|
+
"device": d,
|
|
526
|
+
"started": True,
|
|
527
|
+
"via": "maestro start-device",
|
|
528
|
+
}
|
|
529
|
+
if connected:
|
|
530
|
+
return {
|
|
531
|
+
"ok": True,
|
|
532
|
+
"device": connected[0],
|
|
533
|
+
"started": True,
|
|
534
|
+
"via": "maestro start-device",
|
|
535
|
+
}
|
|
536
|
+
await asyncio.sleep(1.5)
|
|
537
|
+
return {
|
|
538
|
+
"ok": False,
|
|
539
|
+
"error": "maestro_start_device_timeout",
|
|
540
|
+
"message": f"maestro start-device ran but no {plat} device came online",
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
async def ensure_device(
|
|
545
|
+
*,
|
|
546
|
+
platform_pref: str = "android",
|
|
547
|
+
device_id: Optional[str] = None,
|
|
548
|
+
auto_start: bool = True,
|
|
549
|
+
timeout_s: float = 120.0,
|
|
550
|
+
progress: ProgressFn = None,
|
|
551
|
+
use_maestro_cli: bool = True,
|
|
552
|
+
device_model: str = "",
|
|
553
|
+
device_os: str = "",
|
|
554
|
+
device_locale: str = "",
|
|
555
|
+
) -> dict[str, Any]:
|
|
556
|
+
"""Return an online device; optionally start AVD / iOS sim if none connected.
|
|
557
|
+
|
|
558
|
+
Preference order when auto-starting:
|
|
559
|
+
- If ``use_maestro_cli``: try ``maestro start-device`` / ``maestro list-devices``
|
|
560
|
+
- If device_id set: start that AVD name or iOS UDID
|
|
561
|
+
- Else platform android: first available AVD
|
|
562
|
+
- Else platform ios (macOS): first available iPhone simulator
|
|
563
|
+
"""
|
|
564
|
+
plat = (platform_pref or "android").lower()
|
|
565
|
+
|
|
566
|
+
def _p(msg: str) -> None:
|
|
567
|
+
if progress:
|
|
568
|
+
progress(msg)
|
|
569
|
+
|
|
570
|
+
# Explicit device already online?
|
|
571
|
+
connected = await list_connected_devices()
|
|
572
|
+
avd_by_serial = await _online_avd_names() if plat == "android" else {}
|
|
573
|
+
if device_id:
|
|
574
|
+
match = match_connected_device(
|
|
575
|
+
device_id, connected, avd_by_serial=avd_by_serial
|
|
576
|
+
)
|
|
577
|
+
if match:
|
|
578
|
+
return {"ok": True, "device": match, "started": False}
|
|
579
|
+
# Stale serial / AVD label in config while an emulator is already up.
|
|
580
|
+
android_online = [d for d in connected if d.get("platform") == "android"]
|
|
581
|
+
if plat == "android" and android_online:
|
|
582
|
+
pick = android_online[0]
|
|
583
|
+
_p(
|
|
584
|
+
f"Requested device {device_id!r} not in adb list "
|
|
585
|
+
f"(have {[d.get('id') for d in android_online]}); "
|
|
586
|
+
f"using {pick.get('id')}"
|
|
587
|
+
)
|
|
588
|
+
return {"ok": True, "device": pick, "started": False}
|
|
589
|
+
else:
|
|
590
|
+
for d in connected:
|
|
591
|
+
if d.get("platform") == plat:
|
|
592
|
+
_p(f"Using connected {plat} device: {d.get('name')} ({d.get('id')})")
|
|
593
|
+
return {"ok": True, "device": d, "started": False}
|
|
594
|
+
if connected:
|
|
595
|
+
_p(
|
|
596
|
+
f"No {plat} device online — using {connected[0].get('platform')} "
|
|
597
|
+
f"{connected[0].get('name')}"
|
|
598
|
+
)
|
|
599
|
+
return {"ok": True, "device": connected[0], "started": False}
|
|
600
|
+
|
|
601
|
+
if not auto_start:
|
|
602
|
+
return {
|
|
603
|
+
"ok": False,
|
|
604
|
+
"error": "no_device",
|
|
605
|
+
"message": "No devices connected. Start an emulator/simulator or enable auto_start.",
|
|
606
|
+
"connected": connected,
|
|
607
|
+
"targets": await list_all_targets(),
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
# Prefer Maestro CLI device management when available
|
|
611
|
+
if use_maestro_cli:
|
|
612
|
+
started = await _maestro_start_device(
|
|
613
|
+
platform=plat,
|
|
614
|
+
device_model=device_model or device_id or "",
|
|
615
|
+
device_os=device_os,
|
|
616
|
+
device_locale=device_locale,
|
|
617
|
+
timeout_s=timeout_s,
|
|
618
|
+
progress=progress,
|
|
619
|
+
)
|
|
620
|
+
if started.get("ok"):
|
|
621
|
+
return started
|
|
622
|
+
|
|
623
|
+
if device_id:
|
|
624
|
+
# Maybe it's an AVD name or shutdown sim
|
|
625
|
+
avds = await list_android_avds()
|
|
626
|
+
avd = next((a for a in avds if a["id"] == device_id or a["name"] == device_id), None)
|
|
627
|
+
if avd and avd.get("startable") == "true":
|
|
628
|
+
started = await start_android_avd(
|
|
629
|
+
avd["name"], timeout_s=timeout_s, progress=progress
|
|
630
|
+
)
|
|
631
|
+
if started.get("ok"):
|
|
632
|
+
return {
|
|
633
|
+
"ok": True,
|
|
634
|
+
"device": started["device"],
|
|
635
|
+
"started": True,
|
|
636
|
+
"avd": avd["name"],
|
|
637
|
+
}
|
|
638
|
+
return started
|
|
639
|
+
if IS_MAC and re.fullmatch(r"[0-9A-Fa-f-]{36}", device_id):
|
|
640
|
+
started = await start_ios_simulator(
|
|
641
|
+
device_id, timeout_s=timeout_s, progress=progress
|
|
642
|
+
)
|
|
643
|
+
if started.get("ok"):
|
|
644
|
+
return {"ok": True, "device": started["device"], "started": True}
|
|
645
|
+
return started
|
|
646
|
+
return {
|
|
647
|
+
"ok": False,
|
|
648
|
+
"error": "device_not_found",
|
|
649
|
+
"message": f"Device {device_id!r} not connected and could not be started.",
|
|
650
|
+
"connected": await list_connected_devices(),
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
# Auto-start via adb/simctl fallback
|
|
654
|
+
if plat == "ios":
|
|
655
|
+
if not IS_MAC:
|
|
656
|
+
# Fall back to Android on Windows/Linux
|
|
657
|
+
_p("iOS Simulator unavailable on this OS — trying Android AVD…")
|
|
658
|
+
plat = "android"
|
|
659
|
+
else:
|
|
660
|
+
sims = await list_ios_simulators(include_shutdown=True)
|
|
661
|
+
# Prefer iPhone, available (not booted)
|
|
662
|
+
candidates = [
|
|
663
|
+
s
|
|
664
|
+
for s in sims
|
|
665
|
+
if s.get("state") == "available" and "iphone" in s.get("name", "").lower()
|
|
666
|
+
]
|
|
667
|
+
if not candidates:
|
|
668
|
+
candidates = [s for s in sims if s.get("state") == "available"]
|
|
669
|
+
if not candidates:
|
|
670
|
+
return {
|
|
671
|
+
"ok": False,
|
|
672
|
+
"error": "no_ios_simulator",
|
|
673
|
+
"message": "No iOS simulators available. Install Xcode + run Xcode once.",
|
|
674
|
+
}
|
|
675
|
+
pick = candidates[0]
|
|
676
|
+
started = await start_ios_simulator(
|
|
677
|
+
pick["id"], timeout_s=timeout_s, progress=progress
|
|
678
|
+
)
|
|
679
|
+
if started.get("ok"):
|
|
680
|
+
return {"ok": True, "device": started["device"], "started": True}
|
|
681
|
+
return started
|
|
682
|
+
|
|
683
|
+
# Android AVD
|
|
684
|
+
avds = await list_android_avds()
|
|
685
|
+
startable = [a for a in avds if a.get("startable") == "true"]
|
|
686
|
+
if not startable:
|
|
687
|
+
# Maybe emulator binary missing
|
|
688
|
+
if not resolve_emulator():
|
|
689
|
+
return {
|
|
690
|
+
"ok": False,
|
|
691
|
+
"error": "emulator_not_found",
|
|
692
|
+
"message": (
|
|
693
|
+
"No Android emulator binary. Install Android Studio and create an AVD "
|
|
694
|
+
"(Tools → Device Manager). Set ANDROID_HOME if needed."
|
|
695
|
+
),
|
|
696
|
+
}
|
|
697
|
+
return {
|
|
698
|
+
"ok": False,
|
|
699
|
+
"error": "no_avd",
|
|
700
|
+
"message": "No Android Virtual Devices found. Create one in Android Studio Device Manager.",
|
|
701
|
+
}
|
|
702
|
+
pick = startable[0]
|
|
703
|
+
started = await start_android_avd(pick["name"], timeout_s=timeout_s, progress=progress)
|
|
704
|
+
if started.get("ok"):
|
|
705
|
+
return {"ok": True, "device": started["device"], "started": True, "avd": pick["name"]}
|
|
706
|
+
return started
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def host_capabilities() -> dict[str, Any]:
|
|
710
|
+
return {
|
|
711
|
+
"os": platform.system(),
|
|
712
|
+
"android_adb": resolve_adb(),
|
|
713
|
+
"android_emulator": resolve_emulator(),
|
|
714
|
+
"ios_simctl": bool(IS_MAC and shutil.which("xcrun")),
|
|
715
|
+
"can_start_android": bool(resolve_emulator()),
|
|
716
|
+
"can_start_ios": bool(IS_MAC and shutil.which("xcrun")),
|
|
717
|
+
}
|