@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,141 @@
1
+ """First-party Maestro Cloud via `maestro cloud` CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Callable
9
+
10
+ from mobiflow.cloud.base import (
11
+ CloudProvider,
12
+ CloudRunRequest,
13
+ CloudRunResult,
14
+ resolve_credentials,
15
+ )
16
+
17
+ ProgressFn = Callable[[str], None] | None
18
+
19
+
20
+ async def run_maestro_cloud(
21
+ req: CloudRunRequest,
22
+ *,
23
+ progress: ProgressFn = None,
24
+ artifact_dir: Path | None = None,
25
+ ) -> CloudRunResult:
26
+ """Upload app + flows with the official Maestro Cloud CLI."""
27
+ from mobiflow.maestro import _run_cmd, resolve_maestro_binary
28
+
29
+ binary = resolve_maestro_binary()
30
+ if not binary:
31
+ return CloudRunResult(
32
+ ok=False,
33
+ provider="maestro",
34
+ status="error",
35
+ error="maestro_not_installed",
36
+ stdout="",
37
+ stderr="Maestro CLI not found (needed for `maestro cloud`)",
38
+ )
39
+
40
+ try:
41
+ creds = resolve_credentials(
42
+ CloudProvider.MAESTRO,
43
+ username_env=req.username_env,
44
+ access_key_env=req.access_key_env,
45
+ )
46
+ except ValueError as e:
47
+ return CloudRunResult(
48
+ ok=False,
49
+ provider="maestro",
50
+ status="error",
51
+ error="credentials_missing",
52
+ stdout="",
53
+ stderr=str(e),
54
+ )
55
+
56
+ api_key = creds.access_key
57
+ app_file = (req.app_path or "").strip()
58
+ app_binary_id = (req.app_url or "").strip() if not app_file else ""
59
+
60
+ if not app_file and not app_binary_id:
61
+ return CloudRunResult(
62
+ ok=False,
63
+ provider="maestro",
64
+ status="error",
65
+ error="app_missing",
66
+ stdout="",
67
+ stderr="Set device.app_path (.apk/.ipa) or device.app_url (app binary id)",
68
+ )
69
+
70
+ with tempfile.TemporaryDirectory(prefix="mobiflow-maestro-cloud-") as tmp:
71
+ root = Path(tmp)
72
+ flow_path = root / (req.flow_name or "flow.yaml")
73
+ flow_path.write_text(req.flow_yaml, encoding="utf-8")
74
+ for rel, body in (req.scripts or {}).items():
75
+ sp = root / rel
76
+ sp.parent.mkdir(parents=True, exist_ok=True)
77
+ sp.write_text(body, encoding="utf-8")
78
+
79
+ args = [
80
+ binary,
81
+ "cloud",
82
+ "--api-key",
83
+ api_key,
84
+ "--flows",
85
+ str(flow_path),
86
+ "--format",
87
+ "JUNIT",
88
+ "--name",
89
+ req.build_name or req.project or "MobiFlow",
90
+ ]
91
+ if app_file:
92
+ args.extend(["--app-file", app_file])
93
+ elif app_binary_id:
94
+ args.extend(["--app-binary-id", app_binary_id])
95
+ for did in req.devices or []:
96
+ if did and ("-" in did or "_" in did):
97
+ args.extend(["--device-model", did])
98
+ break
99
+ out_xml = root / "report.xml"
100
+ args.extend(["--output", str(out_xml)])
101
+
102
+ if progress:
103
+ progress("Uploading to Maestro Cloud (`maestro cloud`)…")
104
+
105
+ result = await _run_cmd(
106
+ args, timeout=float(req.timeout_s or 1800), cwd=str(root)
107
+ )
108
+ stdout = str(result.get("stdout") or "")
109
+ stderr = str(result.get("stderr") or "")
110
+ ok = bool(result.get("ok"))
111
+
112
+ dashboard = ""
113
+ for line in (stdout + "\n" + stderr).splitlines():
114
+ m = re.search(r"https?://\S+", line)
115
+ if m and "maestro" in m.group(0).lower():
116
+ dashboard = m.group(0).rstrip(").,]")
117
+ break
118
+
119
+ if artifact_dir is not None:
120
+ artifact_dir.mkdir(parents=True, exist_ok=True)
121
+ if out_xml.is_file():
122
+ (artifact_dir / "maestro-junit.xml").write_text(
123
+ out_xml.read_text(encoding="utf-8"), encoding="utf-8"
124
+ )
125
+ (artifact_dir / "maestro-cloud-stdout.txt").write_text(
126
+ stdout, encoding="utf-8"
127
+ )
128
+ (artifact_dir / "maestro-cloud-stderr.txt").write_text(
129
+ stderr, encoding="utf-8"
130
+ )
131
+
132
+ return CloudRunResult(
133
+ ok=ok,
134
+ provider="maestro",
135
+ status="passed" if ok else "failed",
136
+ error=None if ok else str(result.get("error") or "maestro_cloud_failed"),
137
+ stdout=stdout,
138
+ stderr=stderr,
139
+ dashboard_url=dashboard,
140
+ media_dir=str(artifact_dir) if artifact_dir else "",
141
+ )
@@ -0,0 +1,269 @@
1
+ """Download cloud-lab screenshots / video / logs into local run artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from urllib.parse import urlparse
10
+
11
+ import httpx
12
+
13
+ from mobiflow.cloud.base import CloudCredentials
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _URL_KEYS = {
18
+ "screenshots",
19
+ "screenshot",
20
+ "video",
21
+ "video_url",
22
+ "device_log",
23
+ "device_logs",
24
+ "devicelogs",
25
+ "maestro_log",
26
+ "maestro_logs",
27
+ "maestrologs",
28
+ "network_log",
29
+ "network_logs",
30
+ "instrumentation_log",
31
+ }
32
+
33
+
34
+ def collect_media_urls(payload: Any, *, limit: int = 40) -> list[dict[str, str]]:
35
+ """Walk nested JSON and collect media URL entries ``{kind, url}``."""
36
+ found: list[dict[str, str]] = []
37
+ seen: set[str] = set()
38
+
39
+ def _add(kind: str, url: str) -> None:
40
+ u = (url or "").strip()
41
+ if not u.startswith("http"):
42
+ return
43
+ # Strip video time anchors for download
44
+ clean = u.split("#", 1)[0]
45
+ if clean in seen:
46
+ return
47
+ seen.add(clean)
48
+ found.append({"kind": kind, "url": clean})
49
+
50
+ def _walk(node: Any) -> None:
51
+ if len(found) >= limit:
52
+ return
53
+ if isinstance(node, dict):
54
+ for key, val in node.items():
55
+ lk = str(key).lower()
56
+ if lk in _URL_KEYS and isinstance(val, str):
57
+ kind = "screenshot" if "screenshot" in lk else (
58
+ "video" if "video" in lk else "log"
59
+ )
60
+ _add(kind, val)
61
+ else:
62
+ _walk(val)
63
+ elif isinstance(node, list):
64
+ for item in node:
65
+ _walk(item)
66
+
67
+ _walk(payload)
68
+ return found[:limit]
69
+
70
+
71
+ def extract_session_ids(build_payload: dict[str, Any]) -> list[str]:
72
+ """Pull session ids from a BrowserStack Maestro build status payload."""
73
+ ids: list[str] = []
74
+ sessions = build_payload.get("sessions") or []
75
+ if isinstance(sessions, list):
76
+ for sess in sessions:
77
+ if isinstance(sess, dict):
78
+ sid = sess.get("id") or sess.get("session_id") or sess.get("sessionId")
79
+ if sid:
80
+ ids.append(str(sid))
81
+ elif isinstance(sess, str):
82
+ ids.append(sess)
83
+ # Sometimes nested under devices
84
+ devices = build_payload.get("devices") or []
85
+ if isinstance(devices, list):
86
+ for dev in devices:
87
+ if not isinstance(dev, dict):
88
+ continue
89
+ for sess in dev.get("sessions") or []:
90
+ if isinstance(sess, dict):
91
+ sid = sess.get("id") or sess.get("session_id")
92
+ if sid:
93
+ ids.append(str(sid))
94
+ # Deduplicate preserving order
95
+ out: list[str] = []
96
+ seen: set[str] = set()
97
+ for sid in ids:
98
+ if sid not in seen:
99
+ seen.add(sid)
100
+ out.append(sid)
101
+ return out
102
+
103
+
104
+ def guess_filename(kind: str, url: str, index: int) -> str:
105
+ path = urlparse(url).path.rstrip("/")
106
+ leaf = Path(path).name or kind
107
+ # BrowserStack often ends with bare "screenshot" / "video"
108
+ if leaf in {"screenshot", "screenshots", "video", "devicelogs", "maestrologs"}:
109
+ ext = {
110
+ "screenshot": ".png",
111
+ "video": ".mp4",
112
+ "log": ".txt",
113
+ }.get(kind, ".bin")
114
+ return f"{index:02d}-{kind}{ext}"
115
+ if "." not in leaf:
116
+ ext = ".png" if kind == "screenshot" else (".mp4" if kind == "video" else ".txt")
117
+ return f"{index:02d}-{leaf}{ext}"
118
+ return f"{index:02d}-{leaf}"
119
+
120
+
121
+ async def download_media_files(
122
+ client: httpx.AsyncClient,
123
+ items: list[dict[str, str]],
124
+ dest_dir: Path,
125
+ *,
126
+ auth: tuple[str, str] | None = None,
127
+ limit: int = 24,
128
+ ) -> list[str]:
129
+ """Download media URLs into dest_dir; return relative filenames written."""
130
+ dest_dir.mkdir(parents=True, exist_ok=True)
131
+ written: list[str] = []
132
+ for i, item in enumerate(items[:limit], start=1):
133
+ url = item["url"]
134
+ kind = item.get("kind") or "file"
135
+ name = guess_filename(kind, url, i)
136
+ path = dest_dir / name
137
+ try:
138
+ resp = await client.get(url, auth=auth, timeout=120.0, follow_redirects=True)
139
+ if resp.status_code >= 400:
140
+ logger.warning("Cloud media HTTP %s for %s", resp.status_code, url)
141
+ continue
142
+ ctype = (resp.headers.get("content-type") or "").lower()
143
+ # If we guessed wrong extension for JSON error pages, skip
144
+ if (
145
+ "application/json" in ctype
146
+ and kind in {"screenshot", "video"}
147
+ ):
148
+ logger.debug("Skipping JSON media body for %s", url)
149
+ continue
150
+ path.write_bytes(resp.content)
151
+ written.append(name)
152
+ except Exception as exc: # noqa: BLE001
153
+ logger.warning("Cloud media download failed %s: %s", url, exc)
154
+ return written
155
+
156
+
157
+ async def fetch_browserstack_session(
158
+ client: httpx.AsyncClient,
159
+ creds: CloudCredentials,
160
+ build_id: str,
161
+ session_id: str,
162
+ ) -> dict[str, Any]:
163
+ url = (
164
+ "https://api-cloud.browserstack.com/app-automate/maestro/v2/"
165
+ f"builds/{build_id}/sessions/{session_id}"
166
+ )
167
+ resp = await client.get(url, auth=(creds.username, creds.access_key), timeout=60.0)
168
+ resp.raise_for_status()
169
+ return resp.json()
170
+
171
+
172
+ async def pull_browserstack_media(
173
+ creds: CloudCredentials,
174
+ build_id: str,
175
+ build_payload: dict[str, Any],
176
+ dest_dir: Path,
177
+ *,
178
+ progress: Any = None,
179
+ ) -> dict[str, Any]:
180
+ """Fetch session details + download screenshots/video/logs for a BS build."""
181
+ dest_dir.mkdir(parents=True, exist_ok=True)
182
+ urls = collect_media_urls(build_payload)
183
+ session_payloads: list[dict[str, Any]] = []
184
+ async with httpx.AsyncClient() as client:
185
+ for sid in extract_session_ids(build_payload):
186
+ try:
187
+ sess = await fetch_browserstack_session(client, creds, build_id, sid)
188
+ session_payloads.append(sess)
189
+ urls.extend(collect_media_urls(sess))
190
+ except Exception as exc: # noqa: BLE001
191
+ logger.warning("BrowserStack session %s: %s", sid, exc)
192
+ # Deduplicate urls
193
+ dedup: list[dict[str, str]] = []
194
+ seen: set[str] = set()
195
+ for item in urls:
196
+ if item["url"] in seen:
197
+ continue
198
+ seen.add(item["url"])
199
+ dedup.append(item)
200
+ if progress and dedup:
201
+ progress(f"Downloading {len(dedup)} cloud media file(s)…")
202
+ written = await download_media_files(
203
+ client,
204
+ dedup,
205
+ dest_dir,
206
+ auth=(creds.username, creds.access_key),
207
+ )
208
+ # Persist URL index for reports even when download fails
209
+ index = {
210
+ "provider": "browserstack",
211
+ "build_id": build_id,
212
+ "urls": dedup,
213
+ "files": written,
214
+ "sessions": len(session_payloads),
215
+ }
216
+ (dest_dir / "media-index.json").write_text(
217
+ __import__("json").dumps(index, indent=2) + "\n", encoding="utf-8"
218
+ )
219
+ return index
220
+
221
+
222
+ _HTTP_RE = re.compile(r"https?://[^\s\"'<>]+")
223
+
224
+
225
+ def extract_urls_from_text(text: str) -> list[dict[str, str]]:
226
+ """Best-effort media URLs from HyperExecute CLI stdout/stderr."""
227
+ out: list[dict[str, str]] = []
228
+ for match in _HTTP_RE.finditer(text or ""):
229
+ url = match.group(0).rstrip(".,);'\"")
230
+ lower = url.lower()
231
+ if any(x in lower for x in ("screenshot", "video", ".png", ".mp4", "artifact")):
232
+ kind = "video" if "video" in lower or lower.endswith(".mp4") else "screenshot"
233
+ out.append({"kind": kind, "url": url.split("#", 1)[0]})
234
+ return out
235
+
236
+
237
+ async def pull_testmu_media(
238
+ stdout: str,
239
+ stderr: str,
240
+ dest_dir: Path,
241
+ *,
242
+ creds: CloudCredentials | None = None,
243
+ progress: Any = None,
244
+ ) -> dict[str, Any]:
245
+ """Download any media URLs found in TestMu/HyperExecute output."""
246
+ dest_dir.mkdir(parents=True, exist_ok=True)
247
+ urls = extract_urls_from_text(f"{stdout}\n{stderr}")
248
+ written: list[str] = []
249
+ if urls:
250
+ if progress:
251
+ progress(f"Downloading {len(urls)} TestMu media URL(s)…")
252
+ auth = (creds.username, creds.access_key) if creds else None
253
+ async with httpx.AsyncClient() as client:
254
+ written = await download_media_files(client, urls, dest_dir, auth=auth)
255
+ index = {
256
+ "provider": "testmu",
257
+ "urls": urls,
258
+ "files": written,
259
+ "note": (
260
+ "HyperExecute media is best-effort from CLI output; "
261
+ "open dashboard_url when empty."
262
+ if not written
263
+ else ""
264
+ ),
265
+ }
266
+ (dest_dir / "media-index.json").write_text(
267
+ __import__("json").dumps(index, indent=2) + "\n", encoding="utf-8"
268
+ )
269
+ return index
@@ -0,0 +1,156 @@
1
+ """Dispatch Maestro runs to BrowserStack or TestMu."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from mobiflow.cloud.base import (
10
+ CloudProvider,
11
+ CloudRunRequest,
12
+ CloudRunResult,
13
+ devices_from_config,
14
+ is_cloud_provider,
15
+ normalize_provider,
16
+ resolve_credentials,
17
+ )
18
+ from mobiflow.cloud.browserstack import run_browserstack
19
+ from mobiflow.cloud.maestro_cloud import run_maestro_cloud
20
+ from mobiflow.cloud.testmu import resolve_hyperexecute_binary, run_testmu
21
+
22
+ ProgressFn = Callable[[str], None] | None
23
+
24
+
25
+ def cloud_readiness(device_cfg: Any) -> dict[str, Any]:
26
+ """Report whether cloud credentials / tools are ready for device.provider."""
27
+ provider_raw = getattr(device_cfg, "provider", "local") or "local"
28
+ try:
29
+ provider = normalize_provider(provider_raw)
30
+ except ValueError as e:
31
+ return {
32
+ "provider": str(provider_raw),
33
+ "cloud": False,
34
+ "ready": False,
35
+ "message": str(e),
36
+ }
37
+ if not is_cloud_provider(provider):
38
+ return {
39
+ "provider": "local",
40
+ "cloud": False,
41
+ "ready": True,
42
+ "message": "Using local adb / simulators",
43
+ }
44
+
45
+ info: dict[str, Any] = {
46
+ "provider": provider.value,
47
+ "cloud": True,
48
+ "ready": False,
49
+ "app_path": getattr(device_cfg, "app_path", "") or "",
50
+ "app_url": getattr(device_cfg, "app_url", "") or "",
51
+ "device_id": getattr(device_cfg, "device_id", None),
52
+ "real_mobile": bool(getattr(device_cfg, "real_mobile", True)),
53
+ }
54
+ try:
55
+ creds = resolve_credentials(
56
+ provider,
57
+ username_env=getattr(device_cfg, "username_env", "") or "",
58
+ access_key_env=getattr(device_cfg, "access_key_env", "") or "",
59
+ )
60
+ info["username_env"] = creds.username_env
61
+ info["access_key_env"] = creds.access_key_env
62
+ info["credentials"] = True
63
+ except ValueError as e:
64
+ info["credentials"] = False
65
+ info["message"] = str(e)
66
+ return info
67
+
68
+ if provider == CloudProvider.TESTMU:
69
+ he = resolve_hyperexecute_binary()
70
+ info["hyperexecute"] = he or ""
71
+ # CLI can be auto-downloaded on first run
72
+ info["hyperexecute_installable"] = True
73
+ if provider == CloudProvider.MAESTRO:
74
+ from mobiflow.maestro import resolve_maestro_binary
75
+
76
+ info["maestro_cli"] = resolve_maestro_binary() or ""
77
+
78
+ has_app = bool(info["app_path"] or info["app_url"])
79
+ if not has_app:
80
+ info["message"] = "Set device.app_path or device.app_url for cloud runs"
81
+ return info
82
+
83
+ device_label = (info.get("device_id") or "").strip() or "(provider default)"
84
+ info["ready"] = True
85
+ info["message"] = f"{provider.value} credentials OK · device={device_label}"
86
+ return info
87
+
88
+
89
+ def request_from_device_config(
90
+ device_cfg: Any,
91
+ *,
92
+ flow_yaml: str,
93
+ scripts: dict[str, str] | None = None,
94
+ platform: str | None = None,
95
+ device_id: str | None = None,
96
+ timeout_s: int | None = None,
97
+ flow_name: str = "flow.yaml",
98
+ ) -> CloudRunRequest:
99
+ provider = normalize_provider(getattr(device_cfg, "provider", "local"))
100
+ plat = (platform or getattr(device_cfg, "platform", "android") or "android").lower()
101
+ did = device_id if device_id is not None else getattr(device_cfg, "device_id", None)
102
+ devices = devices_from_config(did, platform=plat, provider=provider)
103
+ # Cloud runs can be long; default 30 min unless overridden
104
+ t = timeout_s if timeout_s is not None else 1800
105
+ run_timeout = getattr(device_cfg, "cloud_timeout_s", None)
106
+ if run_timeout:
107
+ t = int(run_timeout)
108
+ return CloudRunRequest(
109
+ provider=provider,
110
+ platform=plat,
111
+ flow_yaml=flow_yaml,
112
+ scripts=dict(scripts or {}),
113
+ devices=devices,
114
+ app_path=(getattr(device_cfg, "app_path", "") or "").strip(),
115
+ app_url=(getattr(device_cfg, "app_url", "") or "").strip(),
116
+ project=(getattr(device_cfg, "cloud_project", "") or "MobiFlow").strip()
117
+ or "MobiFlow",
118
+ build_name=(getattr(device_cfg, "cloud_build_name", "") or "").strip(),
119
+ real_mobile=bool(getattr(device_cfg, "real_mobile", True)),
120
+ username_env=(getattr(device_cfg, "username_env", "") or "").strip(),
121
+ access_key_env=(getattr(device_cfg, "access_key_env", "") or "").strip(),
122
+ timeout_s=int(t),
123
+ poll_interval_s=float(getattr(device_cfg, "poll_interval_s", 15.0) or 15.0),
124
+ local=bool(getattr(device_cfg, "browserstack_local", False)),
125
+ flow_name=flow_name,
126
+ )
127
+
128
+
129
+ async def run_on_cloud(
130
+ request: CloudRunRequest,
131
+ *,
132
+ progress: ProgressFn = None,
133
+ artifact_dir: Any = None,
134
+ ) -> CloudRunResult:
135
+ if request.provider == CloudProvider.BROWSERSTACK:
136
+ return await run_browserstack(
137
+ request, progress=progress, artifact_dir=artifact_dir
138
+ )
139
+ if request.provider == CloudProvider.TESTMU:
140
+ return await run_testmu(
141
+ request, progress=progress, artifact_dir=artifact_dir
142
+ )
143
+ if request.provider == CloudProvider.MAESTRO:
144
+ return await run_maestro_cloud(
145
+ request, progress=progress, artifact_dir=artifact_dir
146
+ )
147
+ return CloudRunResult(
148
+ ok=False,
149
+ provider=str(request.provider),
150
+ error="not_cloud",
151
+ stderr=f"Provider {request.provider} is not a cloud lab",
152
+ )
153
+
154
+
155
+ def env_present(name: str) -> bool:
156
+ return bool(os.environ.get(name, "").strip())