@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,377 @@
|
|
|
1
|
+
"""Detect and auto-install missing runtime packages / tools.
|
|
2
|
+
|
|
3
|
+
Installable during ``mobiflow init`` / ``mobiflow setup``:
|
|
4
|
+
|
|
5
|
+
- Python extras: ``openai``, optional ``anthropic``
|
|
6
|
+
- Maestro CLI (official curl installer)
|
|
7
|
+
- JDK via Homebrew ``openjdk`` when ``brew`` is available
|
|
8
|
+
|
|
9
|
+
Not auto-installed (reported only): Android ``adb`` / full SDK, Xcode simctl.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib.util
|
|
15
|
+
import os
|
|
16
|
+
import platform
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Callable, Optional
|
|
23
|
+
|
|
24
|
+
PrintFn = Callable[[str], None]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class DepStatus:
|
|
29
|
+
id: str
|
|
30
|
+
label: str
|
|
31
|
+
ok: bool
|
|
32
|
+
detail: str = ""
|
|
33
|
+
installable: bool = False
|
|
34
|
+
required: bool = True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class SetupReport:
|
|
39
|
+
items: list[DepStatus] = field(default_factory=list)
|
|
40
|
+
actions: list[str] = field(default_factory=list)
|
|
41
|
+
errors: list[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def missing(self) -> list[DepStatus]:
|
|
45
|
+
return [i for i in self.items if not i.ok]
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def missing_installable(self) -> list[DepStatus]:
|
|
49
|
+
return [i for i in self.missing if i.installable]
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def missing_manual(self) -> list[DepStatus]:
|
|
53
|
+
return [i for i in self.missing if not i.installable and i.required]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _has_module(name: str) -> bool:
|
|
57
|
+
try:
|
|
58
|
+
return importlib.util.find_spec(name) is not None
|
|
59
|
+
except (ModuleNotFoundError, ValueError, AttributeError):
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _which(name: str) -> Optional[str]:
|
|
64
|
+
return shutil.which(name)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _maestro_binary() -> Optional[str]:
|
|
68
|
+
from mobiflow.maestro import resolve_maestro_binary
|
|
69
|
+
|
|
70
|
+
return resolve_maestro_binary()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _java_home() -> Optional[str]:
|
|
74
|
+
from mobiflow.maestro import resolve_java_home
|
|
75
|
+
|
|
76
|
+
return resolve_java_home()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _adb_binary() -> Optional[str]:
|
|
80
|
+
adb = _which("adb")
|
|
81
|
+
if adb:
|
|
82
|
+
return adb
|
|
83
|
+
for candidate in (
|
|
84
|
+
Path.home() / "Library/Android/sdk/platform-tools/adb",
|
|
85
|
+
Path(os.environ.get("ANDROID_HOME") or "") / "platform-tools" / "adb",
|
|
86
|
+
Path(os.environ.get("ANDROID_SDK_ROOT") or "") / "platform-tools" / "adb",
|
|
87
|
+
):
|
|
88
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
89
|
+
return str(candidate)
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def probe_dependencies(*, want_anthropic: bool = False) -> list[DepStatus]:
|
|
94
|
+
"""Return status for each known dependency."""
|
|
95
|
+
items: list[DepStatus] = []
|
|
96
|
+
|
|
97
|
+
# Python core (should already be present if mobiflow is installed)
|
|
98
|
+
for mod, label, required in (
|
|
99
|
+
("openai", "Python package: openai", True),
|
|
100
|
+
("httpx", "Python package: httpx", True),
|
|
101
|
+
("yaml", "Python package: pyyaml", True),
|
|
102
|
+
("pydantic", "Python package: pydantic", True),
|
|
103
|
+
("click", "Python package: click", True),
|
|
104
|
+
("rich", "Python package: rich", True),
|
|
105
|
+
("questionary", "Python package: questionary", True),
|
|
106
|
+
):
|
|
107
|
+
ok = _has_module(mod)
|
|
108
|
+
items.append(
|
|
109
|
+
DepStatus(
|
|
110
|
+
id=f"py:{mod}",
|
|
111
|
+
label=label,
|
|
112
|
+
ok=ok,
|
|
113
|
+
detail="importable" if ok else "missing — will pip install",
|
|
114
|
+
installable=True,
|
|
115
|
+
required=required,
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
anth_ok = _has_module("anthropic")
|
|
120
|
+
if anth_ok:
|
|
121
|
+
anth_detail = "importable"
|
|
122
|
+
elif want_anthropic:
|
|
123
|
+
anth_detail = "missing — needed for Anthropic profiles"
|
|
124
|
+
else:
|
|
125
|
+
anth_detail = "optional — install if using Anthropic codegen"
|
|
126
|
+
items.append(
|
|
127
|
+
DepStatus(
|
|
128
|
+
id="py:anthropic",
|
|
129
|
+
label="Python package: anthropic",
|
|
130
|
+
ok=anth_ok,
|
|
131
|
+
detail=anth_detail,
|
|
132
|
+
installable=True,
|
|
133
|
+
required=want_anthropic,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
maestro = _maestro_binary()
|
|
138
|
+
items.append(
|
|
139
|
+
DepStatus(
|
|
140
|
+
id="maestro",
|
|
141
|
+
label="Maestro CLI",
|
|
142
|
+
ok=bool(maestro),
|
|
143
|
+
detail=maestro or "not found — install via get.maestro.mobile.dev",
|
|
144
|
+
installable=True,
|
|
145
|
+
required=True,
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
jh = _java_home()
|
|
150
|
+
java_bin = _which("java")
|
|
151
|
+
java_ok = bool(jh or java_bin)
|
|
152
|
+
items.append(
|
|
153
|
+
DepStatus(
|
|
154
|
+
id="java",
|
|
155
|
+
label="JDK (JAVA_HOME / java)",
|
|
156
|
+
ok=java_ok,
|
|
157
|
+
detail=jh or java_bin or "not found — Maestro needs a JDK",
|
|
158
|
+
installable=bool(_which("brew")) or platform.system() == "Darwin",
|
|
159
|
+
required=True,
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
adb = _adb_binary()
|
|
164
|
+
items.append(
|
|
165
|
+
DepStatus(
|
|
166
|
+
id="adb",
|
|
167
|
+
label="Android adb (optional)",
|
|
168
|
+
ok=bool(adb),
|
|
169
|
+
detail=adb or "not found — needed for Android emulators/devices",
|
|
170
|
+
installable=bool(_which("brew")),
|
|
171
|
+
required=False,
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if platform.system() == "Darwin":
|
|
176
|
+
xcrun = _which("xcrun")
|
|
177
|
+
items.append(
|
|
178
|
+
DepStatus(
|
|
179
|
+
id="xcrun",
|
|
180
|
+
label="Xcode xcrun / simctl (optional)",
|
|
181
|
+
ok=bool(xcrun),
|
|
182
|
+
detail=xcrun or "not found — needed for iOS Simulator",
|
|
183
|
+
installable=False,
|
|
184
|
+
required=False,
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return items
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _pip_install(*packages: str, log: PrintFn) -> bool:
|
|
192
|
+
if not packages:
|
|
193
|
+
return True
|
|
194
|
+
cmd = [
|
|
195
|
+
sys.executable,
|
|
196
|
+
"-m",
|
|
197
|
+
"pip",
|
|
198
|
+
"install",
|
|
199
|
+
"--upgrade",
|
|
200
|
+
"--prefer-binary",
|
|
201
|
+
*packages,
|
|
202
|
+
]
|
|
203
|
+
log(f" $ {' '.join(cmd)}")
|
|
204
|
+
proc = subprocess.run(cmd, text=True)
|
|
205
|
+
return proc.returncode == 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _install_maestro(*, log: PrintFn) -> bool:
|
|
209
|
+
if _maestro_binary():
|
|
210
|
+
return True
|
|
211
|
+
if not _which("curl"):
|
|
212
|
+
log(" curl not found — cannot download Maestro installer.")
|
|
213
|
+
return False
|
|
214
|
+
log(" Installing Maestro CLI (https://get.maestro.mobile.dev)…")
|
|
215
|
+
# Official installer; non-interactive
|
|
216
|
+
cmd = "curl -Ls 'https://get.maestro.mobile.dev' | bash"
|
|
217
|
+
log(f" $ {cmd}")
|
|
218
|
+
proc = subprocess.run(cmd, shell=True, text=True)
|
|
219
|
+
if proc.returncode != 0:
|
|
220
|
+
return False
|
|
221
|
+
# Ensure ~/.maestro/bin is discoverable in this process
|
|
222
|
+
home_bin = Path.home() / ".maestro" / "bin"
|
|
223
|
+
if home_bin.is_dir():
|
|
224
|
+
os.environ["PATH"] = str(home_bin) + os.pathsep + os.environ.get("PATH", "")
|
|
225
|
+
return bool(_maestro_binary())
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _install_java(*, log: PrintFn) -> bool:
|
|
229
|
+
if _java_home() or _which("java"):
|
|
230
|
+
return True
|
|
231
|
+
brew = _which("brew")
|
|
232
|
+
if not brew:
|
|
233
|
+
log(" Homebrew not found — install a JDK manually, then set JAVA_HOME.")
|
|
234
|
+
return False
|
|
235
|
+
log(" Installing OpenJDK via Homebrew…")
|
|
236
|
+
cmd = [brew, "install", "openjdk"]
|
|
237
|
+
log(f" $ {' '.join(cmd)}")
|
|
238
|
+
proc = subprocess.run(cmd, text=True)
|
|
239
|
+
if proc.returncode != 0:
|
|
240
|
+
return False
|
|
241
|
+
# Common brew link path
|
|
242
|
+
for candidate in (
|
|
243
|
+
"/opt/homebrew/opt/openjdk",
|
|
244
|
+
"/usr/local/opt/openjdk",
|
|
245
|
+
):
|
|
246
|
+
if Path(candidate).is_dir():
|
|
247
|
+
os.environ.setdefault("JAVA_HOME", candidate)
|
|
248
|
+
os.environ["PATH"] = str(Path(candidate) / "bin") + os.pathsep + os.environ.get(
|
|
249
|
+
"PATH", ""
|
|
250
|
+
)
|
|
251
|
+
break
|
|
252
|
+
return bool(_java_home() or _which("java"))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _install_adb(*, log: PrintFn) -> bool:
|
|
256
|
+
if _adb_binary():
|
|
257
|
+
return True
|
|
258
|
+
brew = _which("brew")
|
|
259
|
+
if not brew:
|
|
260
|
+
log(" Homebrew not found — install Android platform-tools manually.")
|
|
261
|
+
return False
|
|
262
|
+
log(" Installing Android platform-tools (adb) via Homebrew…")
|
|
263
|
+
cmd = [brew, "install", "android-platform-tools"]
|
|
264
|
+
log(f" $ {' '.join(cmd)}")
|
|
265
|
+
proc = subprocess.run(cmd, text=True)
|
|
266
|
+
return proc.returncode == 0 and bool(_adb_binary())
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def install_missing(
|
|
270
|
+
*,
|
|
271
|
+
want_anthropic: bool = False,
|
|
272
|
+
install_adb: bool = False,
|
|
273
|
+
log: Optional[PrintFn] = None,
|
|
274
|
+
) -> SetupReport:
|
|
275
|
+
"""Probe then install anything missing that we can auto-fix."""
|
|
276
|
+
_log = log or (lambda m: print(m, file=sys.stderr))
|
|
277
|
+
report = SetupReport(items=probe_dependencies(want_anthropic=want_anthropic))
|
|
278
|
+
|
|
279
|
+
# Python packages
|
|
280
|
+
py_missing = [
|
|
281
|
+
i.id.removeprefix("py:")
|
|
282
|
+
for i in report.items
|
|
283
|
+
if i.id.startswith("py:") and not i.ok and i.installable
|
|
284
|
+
]
|
|
285
|
+
# Map import name → pip name
|
|
286
|
+
pip_map = {"yaml": "pyyaml", "anthropic": "anthropic"}
|
|
287
|
+
to_pip = [pip_map.get(m, m) for m in py_missing]
|
|
288
|
+
# Always ensure openai present
|
|
289
|
+
if not _has_module("openai") and "openai" not in to_pip:
|
|
290
|
+
to_pip.append("openai")
|
|
291
|
+
if want_anthropic and not _has_module("anthropic") and "anthropic" not in to_pip:
|
|
292
|
+
to_pip.append("anthropic")
|
|
293
|
+
|
|
294
|
+
if to_pip:
|
|
295
|
+
_log("Installing missing Python packages…")
|
|
296
|
+
if _pip_install(*to_pip, log=_log):
|
|
297
|
+
report.actions.append(f"pip install {' '.join(to_pip)}")
|
|
298
|
+
else:
|
|
299
|
+
report.errors.append(f"pip install failed: {' '.join(to_pip)}")
|
|
300
|
+
|
|
301
|
+
# Maestro
|
|
302
|
+
maestro_item = next((i for i in report.items if i.id == "maestro"), None)
|
|
303
|
+
if maestro_item and not maestro_item.ok:
|
|
304
|
+
_log("Installing Maestro CLI…")
|
|
305
|
+
if _install_maestro(log=_log):
|
|
306
|
+
report.actions.append("Installed Maestro CLI → ~/.maestro/bin")
|
|
307
|
+
else:
|
|
308
|
+
report.errors.append(
|
|
309
|
+
"Maestro install failed. Manual: curl -Ls https://get.maestro.mobile.dev | bash"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# Java
|
|
313
|
+
java_item = next((i for i in report.items if i.id == "java"), None)
|
|
314
|
+
if java_item and not java_item.ok:
|
|
315
|
+
_log("Installing JDK…")
|
|
316
|
+
if _install_java(log=_log):
|
|
317
|
+
report.actions.append("Installed OpenJDK (Homebrew)")
|
|
318
|
+
else:
|
|
319
|
+
report.errors.append(
|
|
320
|
+
"JDK install failed. Install a JDK and export JAVA_HOME."
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
# adb (optional)
|
|
324
|
+
adb_item = next((i for i in report.items if i.id == "adb"), None)
|
|
325
|
+
if install_adb and adb_item and not adb_item.ok and adb_item.installable:
|
|
326
|
+
_log("Installing Android platform-tools…")
|
|
327
|
+
if _install_adb(log=_log):
|
|
328
|
+
report.actions.append("Installed android-platform-tools (adb)")
|
|
329
|
+
else:
|
|
330
|
+
report.errors.append("adb install failed.")
|
|
331
|
+
|
|
332
|
+
# Re-probe
|
|
333
|
+
report.items = probe_dependencies(want_anthropic=want_anthropic)
|
|
334
|
+
return report
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def ensure_runtime_deps(
|
|
338
|
+
*,
|
|
339
|
+
auto_install: bool = True,
|
|
340
|
+
want_anthropic: bool = False,
|
|
341
|
+
log: Optional[PrintFn] = None,
|
|
342
|
+
) -> SetupReport:
|
|
343
|
+
"""Ensure required tools exist; optionally auto-install missing ones."""
|
|
344
|
+
_log = log or (lambda m: print(m, file=sys.stderr))
|
|
345
|
+
items = probe_dependencies(want_anthropic=want_anthropic)
|
|
346
|
+
missing_req = [i for i in items if not i.ok and i.required]
|
|
347
|
+
if not missing_req:
|
|
348
|
+
return SetupReport(items=items)
|
|
349
|
+
|
|
350
|
+
if not auto_install:
|
|
351
|
+
return SetupReport(items=items)
|
|
352
|
+
|
|
353
|
+
_log("Missing required dependencies — installing…")
|
|
354
|
+
return install_missing(want_anthropic=want_anthropic, log=_log)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def catalog_wants_anthropic(repo: Path | None = None) -> bool:
|
|
358
|
+
"""True if config/catalog selects an Anthropic profile."""
|
|
359
|
+
try:
|
|
360
|
+
from mobiflow.config import find_config, load_config
|
|
361
|
+
|
|
362
|
+
cfg_path = find_config(repo) if repo else find_config()
|
|
363
|
+
if not cfg_path:
|
|
364
|
+
return False
|
|
365
|
+
cfg = load_config(cfg_path.parent)
|
|
366
|
+
for name in (cfg.llm.discovery, cfg.llm.codegen):
|
|
367
|
+
if not name:
|
|
368
|
+
continue
|
|
369
|
+
try:
|
|
370
|
+
entry = cfg.load_catalog().get(name)
|
|
371
|
+
except Exception: # noqa: BLE001
|
|
372
|
+
continue
|
|
373
|
+
if entry.provider.lower() in ("anthropic", "claude"):
|
|
374
|
+
return True
|
|
375
|
+
except Exception: # noqa: BLE001
|
|
376
|
+
return False
|
|
377
|
+
return False
|