@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.
Files changed (39) hide show
  1. package/README.md +9 -11
  2. package/bin/mobiflow.js +94 -61
  3. package/package.json +8 -3
  4. package/pyproject.toml +59 -0
  5. package/src/mobiflow/__init__.py +9 -0
  6. package/src/mobiflow/__main__.py +6 -0
  7. package/src/mobiflow/baseline.py +228 -0
  8. package/src/mobiflow/casedata.py +159 -0
  9. package/src/mobiflow/cases/__init__.py +715 -0
  10. package/src/mobiflow/cli.py +1423 -0
  11. package/src/mobiflow/cloud/__init__.py +28 -0
  12. package/src/mobiflow/cloud/base.py +272 -0
  13. package/src/mobiflow/cloud/browserstack.py +330 -0
  14. package/src/mobiflow/cloud/maestro_cloud.py +141 -0
  15. package/src/mobiflow/cloud/media.py +269 -0
  16. package/src/mobiflow/cloud/runner.py +156 -0
  17. package/src/mobiflow/cloud/testmu.py +378 -0
  18. package/src/mobiflow/config/__init__.py +538 -0
  19. package/src/mobiflow/deps.py +377 -0
  20. package/src/mobiflow/devices.py +717 -0
  21. package/src/mobiflow/explore.py +623 -0
  22. package/src/mobiflow/incremental.py +198 -0
  23. package/src/mobiflow/init/__init__.py +794 -0
  24. package/src/mobiflow/llm.py +462 -0
  25. package/src/mobiflow/llm_catalog.py +232 -0
  26. package/src/mobiflow/maestro/__init__.py +1506 -0
  27. package/src/mobiflow/maestro/lifecycle.py +279 -0
  28. package/src/mobiflow/pipeline.py +600 -0
  29. package/src/mobiflow/report/__init__.py +617 -0
  30. package/src/mobiflow/report/static/favicon.jpg +0 -0
  31. package/src/mobiflow/report/static/favicon.svg +1 -0
  32. package/src/mobiflow/report/static/icons.svg +24 -0
  33. package/src/mobiflow/report/static/index.html +99 -0
  34. package/src/mobiflow/report/static/mobiflow-mark.jpg +0 -0
  35. package/src/mobiflow/reporting.py +682 -0
  36. package/src/mobiflow/sample_apps.py +259 -0
  37. package/src/mobiflow/secrets.py +90 -0
  38. package/src/mobiflow/selectors.py +128 -0
  39. package/src/mobiflow/suite.py +263 -0
@@ -0,0 +1,279 @@
1
+ """App lifecycle helpers: install APK/IPA and clearState preflight flows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import shutil
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def normalize_preflight(value: Any) -> list[str]:
15
+ """Accept list/comma-string; return unique lowercase steps: install, clear."""
16
+ if value is None:
17
+ return []
18
+ if isinstance(value, str):
19
+ items = [p.strip().lower() for p in value.replace(";", ",").split(",")]
20
+ elif isinstance(value, (list, tuple, set)):
21
+ items = [str(p).strip().lower() for p in value]
22
+ else:
23
+ items = [str(value).strip().lower()]
24
+ aliases = {
25
+ "install": "install",
26
+ "installapp": "install",
27
+ "apk": "install",
28
+ "clear": "clear",
29
+ "clearstate": "clear",
30
+ "clear_state": "clear",
31
+ "reset": "clear",
32
+ }
33
+ out: list[str] = []
34
+ for item in items:
35
+ if not item or item in {"none", "off", "false", "0"}:
36
+ continue
37
+ step = aliases.get(item.replace("-", "").replace("_", ""), item)
38
+ if step in {"install", "clear"} and step not in out:
39
+ out.append(step)
40
+ return out
41
+
42
+
43
+ def build_preflight_flow_yaml(
44
+ app_id: str,
45
+ *,
46
+ clear_state: bool = True,
47
+ clear_keychain: bool = False,
48
+ platform: str = "android",
49
+ ) -> str:
50
+ """Tiny Maestro flow: optional clearState (+ iOS clearKeychain) then stopApp."""
51
+ aid = (app_id or "").strip() or "unknown.app"
52
+ lines = [
53
+ f"appId: {aid}",
54
+ "name: MobiFlow preflight",
55
+ "---",
56
+ ]
57
+ if clear_state:
58
+ lines.append("- clearState")
59
+ if clear_keychain and (platform or "").lower() == "ios":
60
+ lines.append("- clearKeychain")
61
+ lines.append("- stopApp")
62
+ return "\n".join(lines) + "\n"
63
+
64
+
65
+ def resolve_adb() -> str | None:
66
+ which = shutil.which("adb")
67
+ if which:
68
+ return which
69
+ for env_key in ("ANDROID_HOME", "ANDROID_SDK_ROOT"):
70
+ root = os.environ.get(env_key)
71
+ if not root:
72
+ continue
73
+ candidate = Path(root) / "platform-tools" / "adb"
74
+ if candidate.is_file() and os.access(candidate, os.X_OK):
75
+ return str(candidate)
76
+ mac = Path.home() / "Library" / "Android" / "sdk" / "platform-tools" / "adb"
77
+ if mac.is_file() and os.access(mac, os.X_OK):
78
+ return str(mac)
79
+ return None
80
+
81
+
82
+ async def install_app_local(
83
+ app_path: str | Path,
84
+ *,
85
+ device_id: str | None = None,
86
+ platform: str = "android",
87
+ timeout_s: float = 180.0,
88
+ ) -> dict[str, Any]:
89
+ """Install a local .apk / .aab / .ipa onto a device or simulator."""
90
+ import asyncio
91
+
92
+ path = Path(app_path).expanduser().resolve()
93
+ plat = (platform or "android").lower()
94
+ suffix = path.suffix.lower()
95
+ is_ios_app_bundle = suffix == ".app" and path.is_dir()
96
+ if not path.is_file() and not is_ios_app_bundle:
97
+ return {
98
+ "ok": False,
99
+ "error": "app_not_found",
100
+ "message": f"App package not found: {path}",
101
+ }
102
+
103
+ if plat == "android" or suffix in {".apk", ".aab"}:
104
+ adb = resolve_adb()
105
+ if not adb:
106
+ return {
107
+ "ok": False,
108
+ "error": "adb_not_found",
109
+ "message": "adb not found — install Android platform-tools",
110
+ }
111
+ args = [adb]
112
+ if device_id:
113
+ args.extend(["-s", device_id])
114
+ # -r: replace existing; -d: allow version downgrade (helpful in CI)
115
+ args.extend(["install", "-r", "-d", str(path)])
116
+ try:
117
+ proc = await asyncio.create_subprocess_exec(
118
+ *args,
119
+ stdout=asyncio.subprocess.PIPE,
120
+ stderr=asyncio.subprocess.PIPE,
121
+ )
122
+ stdout_b, stderr_b = await asyncio.wait_for(
123
+ proc.communicate(), timeout=timeout_s
124
+ )
125
+ except TimeoutError:
126
+ return {
127
+ "ok": False,
128
+ "error": "install_timeout",
129
+ "message": f"adb install timed out after {timeout_s}s",
130
+ }
131
+ stdout = (stdout_b or b"").decode("utf-8", errors="replace")
132
+ stderr = (stderr_b or b"").decode("utf-8", errors="replace")
133
+ ok = proc.returncode == 0 and "Success" in (stdout + stderr)
134
+ return {
135
+ "ok": ok,
136
+ "error": "" if ok else "install_failed",
137
+ "message": "Installed via adb" if ok else (stderr or stdout or "adb install failed"),
138
+ "stdout": stdout,
139
+ "stderr": stderr,
140
+ "returncode": proc.returncode,
141
+ "app_path": str(path),
142
+ }
143
+
144
+ if plat == "ios" or suffix in {".ipa", ".app"}:
145
+ # Simulator path: xcrun simctl install <udid> <app.app>
146
+ # .ipa needs unzip; prefer .app directories for sims.
147
+ if is_ios_app_bundle:
148
+ udid = device_id or "booted"
149
+ args = ["xcrun", "simctl", "install", udid, str(path)]
150
+ try:
151
+ proc = await asyncio.create_subprocess_exec(
152
+ *args,
153
+ stdout=asyncio.subprocess.PIPE,
154
+ stderr=asyncio.subprocess.PIPE,
155
+ )
156
+ stdout_b, stderr_b = await asyncio.wait_for(
157
+ proc.communicate(), timeout=timeout_s
158
+ )
159
+ except FileNotFoundError:
160
+ return {
161
+ "ok": False,
162
+ "error": "simctl_not_found",
163
+ "message": "xcrun simctl not found (macOS + Xcode required)",
164
+ }
165
+ except TimeoutError:
166
+ return {
167
+ "ok": False,
168
+ "error": "install_timeout",
169
+ "message": f"simctl install timed out after {timeout_s}s",
170
+ }
171
+ stdout = (stdout_b or b"").decode("utf-8", errors="replace")
172
+ stderr = (stderr_b or b"").decode("utf-8", errors="replace")
173
+ ok = proc.returncode == 0
174
+ return {
175
+ "ok": ok,
176
+ "error": "" if ok else "install_failed",
177
+ "message": "Installed via simctl" if ok else (stderr or stdout),
178
+ "stdout": stdout,
179
+ "stderr": stderr,
180
+ "returncode": proc.returncode,
181
+ "app_path": str(path),
182
+ }
183
+ return {
184
+ "ok": False,
185
+ "error": "ios_package_unsupported",
186
+ "message": (
187
+ "Local iOS install supports .app on Simulator. "
188
+ "For .ipa use a cloud provider or install manually."
189
+ ),
190
+ "app_path": str(path),
191
+ }
192
+
193
+ return {
194
+ "ok": False,
195
+ "error": "unsupported_package",
196
+ "message": f"Unsupported app package: {path.name}",
197
+ "app_path": str(path),
198
+ }
199
+
200
+
201
+ async def run_preflight(
202
+ *,
203
+ app_id: str,
204
+ platform: str,
205
+ device_id: str | None,
206
+ steps: list[str],
207
+ app_path: str = "",
208
+ clear_state: bool = False,
209
+ progress: Any = None,
210
+ run_flow_yaml: Any = None,
211
+ timeout_s: int = 90,
212
+ ) -> dict[str, Any]:
213
+ """Run configured lifecycle steps on a local device.
214
+
215
+ Cloud labs install via upload — skip local install/clear there.
216
+ """
217
+ from mobiflow.maestro import run_flow_yaml as _default_run
218
+
219
+ runner = run_flow_yaml or _default_run
220
+ done: list[str] = []
221
+ notes: list[str] = []
222
+
223
+ want = list(steps)
224
+ if clear_state and "clear" not in want:
225
+ want.append("clear")
226
+
227
+ if "install" in want and app_path:
228
+ if progress:
229
+ progress(f"Preflight: installing {app_path}…")
230
+ inst = await install_app_local(
231
+ app_path,
232
+ device_id=device_id,
233
+ platform=platform,
234
+ timeout_s=float(timeout_s),
235
+ )
236
+ notes.append(inst.get("message") or "")
237
+ if not inst.get("ok"):
238
+ return {
239
+ "ok": False,
240
+ "steps": done,
241
+ "error": inst.get("error") or "install_failed",
242
+ "message": inst.get("message") or "install failed",
243
+ "install": inst,
244
+ }
245
+ done.append("install")
246
+ elif "install" in want and not app_path:
247
+ notes.append("preflight install skipped — no app_path")
248
+
249
+ if "clear" in want:
250
+ if not app_id:
251
+ notes.append("preflight clear skipped — no app_id")
252
+ else:
253
+ if progress:
254
+ progress(f"Preflight: clearState {app_id}…")
255
+ flow = build_preflight_flow_yaml(
256
+ app_id,
257
+ clear_state=True,
258
+ clear_keychain=(platform or "").lower() == "ios",
259
+ platform=platform,
260
+ )
261
+ result = await runner(
262
+ flow,
263
+ device_id=device_id,
264
+ timeout_s=timeout_s,
265
+ )
266
+ if not result.get("ok"):
267
+ return {
268
+ "ok": False,
269
+ "steps": done,
270
+ "error": result.get("error") or "clear_failed",
271
+ "message": result.get("stderr")
272
+ or result.get("error")
273
+ or "clearState failed",
274
+ "clear": result,
275
+ "notes": notes,
276
+ }
277
+ done.append("clear")
278
+
279
+ return {"ok": True, "steps": done, "notes": notes, "message": "preflight ok"}