@qubiqlabs/mobiflow 0.9.1 → 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 +73 -28
  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,378 @@
1
+ """TestMu AI (formerly LambdaTest) Maestro via HyperExecute."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import platform
9
+ import shutil
10
+ import stat
11
+ import tempfile
12
+ from collections.abc import Callable
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import httpx
17
+ import yaml
18
+
19
+ from mobiflow.cloud.base import (
20
+ CloudCredentials,
21
+ CloudProvider,
22
+ CloudRunRequest,
23
+ CloudRunResult,
24
+ resolve_credentials,
25
+ write_suite_dir,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ ProgressFn = Callable[[str], None] | None
31
+
32
+ UPLOAD_REAL = "https://manual-api.lambdatest.com/app/upload/realDevice"
33
+ UPLOAD_VIRTUAL = "https://manual-api.lambdatest.com/app/upload/virtualDevice"
34
+
35
+ _HYPEREXECUTE_URLS = {
36
+ "Linux": "https://downloads.lambdatest.com/hyperexecute/linux/hyperexecute",
37
+ "Darwin": "https://downloads.lambdatest.com/hyperexecute/darwin/hyperexecute",
38
+ "Windows": "https://downloads.lambdatest.com/hyperexecute/windows/hyperexecute.exe",
39
+ }
40
+
41
+
42
+ def _auth(creds: CloudCredentials) -> tuple[str, str]:
43
+ return (creds.username, creds.access_key)
44
+
45
+
46
+ def hyperexecute_bin_dir() -> Path:
47
+ return Path.home() / ".mobiflow" / "bin"
48
+
49
+
50
+ def resolve_hyperexecute_binary() -> str | None:
51
+ which = shutil.which("hyperexecute")
52
+ if which:
53
+ return which
54
+ name = "hyperexecute.exe" if platform.system() == "Windows" else "hyperexecute"
55
+ candidate = hyperexecute_bin_dir() / name
56
+ if candidate.is_file() and os.access(candidate, os.X_OK):
57
+ return str(candidate)
58
+ return None
59
+
60
+
61
+ async def ensure_hyperexecute(progress: ProgressFn = None) -> str:
62
+ existing = resolve_hyperexecute_binary()
63
+ if existing:
64
+ return existing
65
+ system = platform.system()
66
+ url = _HYPEREXECUTE_URLS.get(system)
67
+ if not url:
68
+ raise RuntimeError(f"No HyperExecute CLI download for OS={system}")
69
+ dest_dir = hyperexecute_bin_dir()
70
+ dest_dir.mkdir(parents=True, exist_ok=True)
71
+ name = "hyperexecute.exe" if system == "Windows" else "hyperexecute"
72
+ dest = dest_dir / name
73
+ if progress:
74
+ progress(f"Downloading HyperExecute CLI ({system})…")
75
+ async with httpx.AsyncClient(follow_redirects=True) as client:
76
+ resp = await client.get(url, timeout=300.0)
77
+ resp.raise_for_status()
78
+ dest.write_bytes(resp.content)
79
+ dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
80
+ return str(dest)
81
+
82
+
83
+ async def upload_app(
84
+ client: httpx.AsyncClient,
85
+ creds: CloudCredentials,
86
+ app_path: Path,
87
+ *,
88
+ real_mobile: bool = True,
89
+ name: str = "mobiflow-app",
90
+ ) -> str:
91
+ if not app_path.is_file():
92
+ raise FileNotFoundError(f"App not found: {app_path}")
93
+ endpoint = UPLOAD_REAL if real_mobile else UPLOAD_VIRTUAL
94
+ files = {"appFile": (app_path.name, app_path.read_bytes())}
95
+ data = {"name": name}
96
+ resp = await client.post(
97
+ endpoint, auth=_auth(creds), data=data, files=files, timeout=600.0
98
+ )
99
+ resp.raise_for_status()
100
+ payload = resp.json()
101
+ url = payload.get("app_url") or payload.get("appUrl") or payload.get("app_id")
102
+ if not url:
103
+ raise RuntimeError(f"TestMu app upload missing app_url: {payload}")
104
+ url = str(url)
105
+ if not url.startswith("lt://") and url.startswith("APP"):
106
+ url = f"lt://{url}"
107
+ return url
108
+
109
+
110
+ def render_hyperexecute_yaml(
111
+ *,
112
+ platform: str,
113
+ devices: list[str],
114
+ app_url: str = "",
115
+ app_path: str = "",
116
+ build_name: str,
117
+ real_mobile: bool,
118
+ flow_relpath: str,
119
+ project_label: str = "MobiFlow",
120
+ ) -> str:
121
+ plat = (platform or "android").lower()
122
+ runson = "ios" if plat == "ios" else "android"
123
+ args: dict[str, Any] = {
124
+ "devices": devices,
125
+ "video": True,
126
+ "deviceLog": True,
127
+ "buildName": build_name or "mobiflow-maestro",
128
+ "queueTimeout": 600,
129
+ "isRealMobile": bool(real_mobile),
130
+ "network": True,
131
+ "platformName": plat,
132
+ "disableReleaseDevice": True,
133
+ "reservation": False,
134
+ }
135
+ if app_url:
136
+ args["appId"] = app_url
137
+ elif app_path:
138
+ args["appPath"] = app_path
139
+
140
+ doc: dict[str, Any] = {
141
+ "version": "0.2",
142
+ "autosplit": True,
143
+ "concurrency": 1,
144
+ "runson": runson,
145
+ "dynamicAllocation": True,
146
+ "runtime": [{"language": "java", "version": "21"}],
147
+ "framework": {"name": "raw", "args": args},
148
+ "env": {"MAESTRO": True, "MAESTRO_LOGS_DIR": "MaestroLogs"},
149
+ "pre": [
150
+ "curl -Ls 'https://get.maestro.mobile.dev' | bash",
151
+ "export PATH=\"$PATH:$HOME/.maestro/bin\"",
152
+ "maestro --version || true",
153
+ ],
154
+ "testDiscovery": {
155
+ "command": f"echo {flow_relpath}",
156
+ "mode": "static",
157
+ "type": "raw",
158
+ },
159
+ "testRunnerCommand": (
160
+ 'export PATH="$PATH:$HOME/.maestro/bin"; '
161
+ "maestro test $test --format junit"
162
+ ),
163
+ "frameworkStatusOnly": True,
164
+ "report": True,
165
+ "partialReports": [
166
+ {"location": ".", "type": "xml", "frameworkName": "junit"}
167
+ ],
168
+ "jobLabel": ["MobiFlow", "Maestro", project_label, plat],
169
+ }
170
+ return yaml.safe_dump(doc, sort_keys=False)
171
+
172
+
173
+ async def run_testmu(
174
+ request: CloudRunRequest,
175
+ *,
176
+ progress: ProgressFn = None,
177
+ artifact_dir: Path | None = None,
178
+ ) -> CloudRunResult:
179
+ creds = resolve_credentials(
180
+ CloudProvider.TESTMU,
181
+ username_env=request.username_env,
182
+ access_key_env=request.access_key_env,
183
+ )
184
+ devices = list(request.devices)
185
+ if not devices:
186
+ return CloudRunResult(
187
+ ok=False,
188
+ provider="testmu",
189
+ error="no_devices",
190
+ stderr="device.device_id (cloud device name) is required for TestMu",
191
+ )
192
+
193
+ app_url = (request.app_url or "").strip()
194
+ app_path_rel = ""
195
+ flow_name = request.flow_name or "flow.yaml"
196
+
197
+ try:
198
+ hyperexecute = await ensure_hyperexecute(progress=progress)
199
+ except Exception as e: # noqa: BLE001
200
+ return CloudRunResult(
201
+ ok=False,
202
+ provider="testmu",
203
+ error="hyperexecute_missing",
204
+ stderr=str(e),
205
+ )
206
+
207
+ with tempfile.TemporaryDirectory(prefix="mobiflow-testmu-") as tmp:
208
+ root = Path(tmp)
209
+ suite_dir = root / "maestro-suite"
210
+ write_suite_dir(
211
+ suite_dir,
212
+ request.flow_yaml,
213
+ request.scripts,
214
+ flow_name=flow_name,
215
+ )
216
+ flow_relpath = f"maestro-suite/{flow_name}"
217
+
218
+ # Prefer uploaded app_url; else upload local app_path; else copy app into job
219
+ async with httpx.AsyncClient() as client:
220
+ if not app_url and request.app_path:
221
+ src = Path(request.app_path)
222
+ if progress:
223
+ progress(f"Uploading app to TestMu: {src}")
224
+ try:
225
+ app_url = await upload_app(
226
+ client,
227
+ creds,
228
+ src,
229
+ real_mobile=request.real_mobile,
230
+ )
231
+ if progress:
232
+ progress(f"App uploaded → {app_url}")
233
+ except Exception as e: # noqa: BLE001
234
+ # Fall back to shipping the binary with the HyperExecute job
235
+ logger.warning("TestMu upload failed (%s); using appPath in job", e)
236
+ dest = suite_dir / src.name
237
+ dest.write_bytes(src.read_bytes())
238
+ app_path_rel = f"maestro-suite/{src.name}"
239
+
240
+ if not app_url and not app_path_rel and not request.app_path:
241
+ return CloudRunResult(
242
+ ok=False,
243
+ provider="testmu",
244
+ error="app_missing",
245
+ stderr=(
246
+ "Set device.app_path (.apk/.ipa) or device.app_url (lt://…) "
247
+ "for TestMu."
248
+ ),
249
+ )
250
+
251
+ he_yaml = render_hyperexecute_yaml(
252
+ platform=request.platform,
253
+ devices=devices,
254
+ app_url=app_url,
255
+ app_path=app_path_rel,
256
+ build_name=request.build_name or "mobiflow-maestro",
257
+ real_mobile=request.real_mobile,
258
+ flow_relpath=flow_relpath,
259
+ project_label=request.project or "MobiFlow",
260
+ )
261
+ he_path = root / "hyperexecute.yaml"
262
+ he_path.write_text(he_yaml, encoding="utf-8")
263
+ if progress:
264
+ progress("Starting TestMu HyperExecute Maestro job…")
265
+
266
+ env = dict(os.environ)
267
+ env["LT_USERNAME"] = creds.username
268
+ env["LT_ACCESS_KEY"] = creds.access_key
269
+ env.setdefault("TESTMU_USERNAME", creds.username)
270
+ env.setdefault("TESTMU_ACCESS_KEY", creds.access_key)
271
+
272
+ args = [
273
+ hyperexecute,
274
+ "--user",
275
+ creds.username,
276
+ "--key",
277
+ creds.access_key,
278
+ "--config",
279
+ str(he_path),
280
+ "--no-track",
281
+ ]
282
+ try:
283
+ proc = await asyncio.create_subprocess_exec(
284
+ *args,
285
+ stdout=asyncio.subprocess.PIPE,
286
+ stderr=asyncio.subprocess.PIPE,
287
+ cwd=str(root),
288
+ env=env,
289
+ )
290
+ except FileNotFoundError as e:
291
+ return CloudRunResult(
292
+ ok=False,
293
+ provider="testmu",
294
+ error="hyperexecute_not_found",
295
+ stderr=str(e),
296
+ )
297
+
298
+ try:
299
+ stdout_b, stderr_b = await asyncio.wait_for(
300
+ proc.communicate(), timeout=float(request.timeout_s)
301
+ )
302
+ except TimeoutError:
303
+ try:
304
+ proc.kill()
305
+ except ProcessLookupError:
306
+ pass
307
+ return CloudRunResult(
308
+ ok=False,
309
+ provider="testmu",
310
+ error="timeout",
311
+ stderr=f"HyperExecute timed out after {request.timeout_s}s",
312
+ )
313
+
314
+ stdout = (stdout_b or b"").decode("utf-8", errors="replace")
315
+ stderr = (stderr_b or b"").decode("utf-8", errors="replace")
316
+ code = proc.returncode if proc.returncode is not None else -1
317
+ ok = code == 0
318
+ # Best-effort job / dashboard extraction
319
+ build_id = ""
320
+ dash = "https://hyperexecute.lambdatest.com/hyperexecute"
321
+ for line in (stdout + "\n" + stderr).splitlines():
322
+ if "hyperexecute.lambdatest.com" in line or "testmuai.com" in line:
323
+ for token in line.split():
324
+ if token.startswith("http"):
325
+ dash = token.strip(".,)'\"")
326
+ break
327
+ if "Job Id" in line or "job id" in line.lower():
328
+ parts = line.replace(":", " ").split()
329
+ for i, p in enumerate(parts):
330
+ if p.lower() in {"id", "job"} and i + 1 < len(parts):
331
+ build_id = parts[i + 1]
332
+ if progress:
333
+ progress(
334
+ f"TestMu HyperExecute finished rc={code}"
335
+ + (f" · {dash}" if dash else "")
336
+ )
337
+ media_urls: list[dict[str, str]] = []
338
+ media_files: list[str] = []
339
+ media_dir = ""
340
+ video_url = ""
341
+ if artifact_dir is not None:
342
+ try:
343
+ from mobiflow.cloud.media import pull_testmu_media
344
+
345
+ media_dest = Path(artifact_dir) / "cloud"
346
+ media_index = await pull_testmu_media(
347
+ stdout,
348
+ stderr,
349
+ media_dest,
350
+ creds=creds,
351
+ progress=progress,
352
+ )
353
+ media_urls = list(media_index.get("urls") or [])
354
+ media_files = list(media_index.get("files") or [])
355
+ media_dir = str(media_dest)
356
+ for item in media_urls:
357
+ if item.get("kind") == "video":
358
+ video_url = item.get("url") or ""
359
+ break
360
+ except Exception as exc: # noqa: BLE001
361
+ logger.warning("TestMu media pull failed: %s", exc)
362
+
363
+ return CloudRunResult(
364
+ ok=ok,
365
+ provider="testmu",
366
+ build_id=build_id,
367
+ status="passed" if ok else "failed",
368
+ dashboard_url=dash,
369
+ app_url=app_url,
370
+ stdout=stdout,
371
+ stderr=stderr,
372
+ error=None if ok else "hyperexecute_failed",
373
+ raw={"returncode": code, "hyperexecute_yaml": he_yaml},
374
+ media_urls=media_urls,
375
+ media_files=media_files,
376
+ media_dir=media_dir,
377
+ video_url=video_url,
378
+ )