@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,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"}
|