@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,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())
|