@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.
- package/README.md +9 -11
- package/bin/mobiflow.js +73 -28
- 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,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
|
+
)
|