@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,28 @@
1
+ """Cloud device labs: BrowserStack App Automate + TestMu (HyperExecute) for Maestro."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mobiflow.cloud.base import (
6
+ CloudCredentials,
7
+ CloudProvider,
8
+ CloudRunRequest,
9
+ CloudRunResult,
10
+ is_cloud_provider,
11
+ normalize_provider,
12
+ resolve_credentials,
13
+ zip_maestro_suite,
14
+ )
15
+ from mobiflow.cloud.runner import cloud_readiness, run_on_cloud
16
+
17
+ __all__ = [
18
+ "CloudCredentials",
19
+ "CloudProvider",
20
+ "CloudRunRequest",
21
+ "CloudRunResult",
22
+ "cloud_readiness",
23
+ "is_cloud_provider",
24
+ "normalize_provider",
25
+ "resolve_credentials",
26
+ "run_on_cloud",
27
+ "zip_maestro_suite",
28
+ ]
@@ -0,0 +1,272 @@
1
+ """Shared types and helpers for cloud Maestro runners."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import os
7
+ import zipfile
8
+ from dataclasses import dataclass, field
9
+ from enum import Enum
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ class CloudProvider(str, Enum):
15
+ LOCAL = "local"
16
+ BROWSERSTACK = "browserstack"
17
+ TESTMU = "testmu"
18
+ MAESTRO = "maestro"
19
+
20
+
21
+ _PROVIDER_ALIASES = {
22
+ "local": CloudProvider.LOCAL,
23
+ "none": CloudProvider.LOCAL,
24
+ "": CloudProvider.LOCAL,
25
+ "browserstack": CloudProvider.BROWSERSTACK,
26
+ "bs": CloudProvider.BROWSERSTACK,
27
+ "bstack": CloudProvider.BROWSERSTACK,
28
+ "testmu": CloudProvider.TESTMU,
29
+ "testmuai": CloudProvider.TESTMU,
30
+ "testmu-ai": CloudProvider.TESTMU,
31
+ "lambdatest": CloudProvider.TESTMU,
32
+ "lt": CloudProvider.TESTMU,
33
+ "maestro": CloudProvider.MAESTRO,
34
+ "maestro-cloud": CloudProvider.MAESTRO,
35
+ "maestro_cloud": CloudProvider.MAESTRO,
36
+ "maestrocloud": CloudProvider.MAESTRO,
37
+ }
38
+
39
+
40
+ def normalize_provider(value: str | None) -> CloudProvider:
41
+ key = (value or "local").strip().lower().replace("_", "-")
42
+ if key not in _PROVIDER_ALIASES:
43
+ raise ValueError(
44
+ f"Unknown device.provider {value!r}. "
45
+ "Use: local | browserstack | testmu | maestro"
46
+ )
47
+ return _PROVIDER_ALIASES[key]
48
+
49
+
50
+ def is_cloud_provider(value: str | CloudProvider | None) -> bool:
51
+ if isinstance(value, CloudProvider):
52
+ return value in (
53
+ CloudProvider.BROWSERSTACK,
54
+ CloudProvider.TESTMU,
55
+ CloudProvider.MAESTRO,
56
+ )
57
+ try:
58
+ return normalize_provider(value) in (
59
+ CloudProvider.BROWSERSTACK,
60
+ CloudProvider.TESTMU,
61
+ CloudProvider.MAESTRO,
62
+ )
63
+ except ValueError:
64
+ return False
65
+
66
+
67
+ @dataclass
68
+ class CloudCredentials:
69
+ username: str
70
+ access_key: str
71
+ username_env: str
72
+ access_key_env: str
73
+
74
+
75
+ def default_credential_env_names(provider: CloudProvider) -> tuple[str, str]:
76
+ if provider == CloudProvider.BROWSERSTACK:
77
+ return "BROWSERSTACK_USERNAME", "BROWSERSTACK_ACCESS_KEY"
78
+ if provider == CloudProvider.TESTMU:
79
+ # Prefer TestMu names; fall back to legacy LambdaTest names at resolve time.
80
+ return "TESTMU_USERNAME", "TESTMU_ACCESS_KEY"
81
+ if provider == CloudProvider.MAESTRO:
82
+ # Maestro Cloud uses a single API key (stored in access_key slot).
83
+ return "MAESTRO_CLOUD_API_KEY", "MAESTRO_CLOUD_API_KEY"
84
+ return "", ""
85
+
86
+
87
+ def resolve_credentials(
88
+ provider: CloudProvider,
89
+ *,
90
+ username_env: str = "",
91
+ access_key_env: str = "",
92
+ ) -> CloudCredentials:
93
+ user_env, key_env = default_credential_env_names(provider)
94
+ if (username_env or "").strip():
95
+ user_env = username_env.strip()
96
+ if (access_key_env or "").strip():
97
+ key_env = access_key_env.strip()
98
+
99
+ username = os.environ.get(user_env, "").strip()
100
+ access_key = os.environ.get(key_env, "").strip()
101
+
102
+ # Maestro Cloud: single API key (also accept MAESTRO_API_KEY)
103
+ if provider == CloudProvider.MAESTRO:
104
+ for alt in ("MAESTRO_CLOUD_API_KEY", "MAESTRO_API_KEY", "MAESTRO_CLOUD_KEY"):
105
+ val = os.environ.get(alt, "").strip()
106
+ if val:
107
+ return CloudCredentials(
108
+ username=val,
109
+ access_key=val,
110
+ username_env=alt,
111
+ access_key_env=alt,
112
+ )
113
+ raise ValueError(
114
+ "Cloud credentials missing for maestro. "
115
+ "Export $MAESTRO_CLOUD_API_KEY (or $MAESTRO_API_KEY)."
116
+ )
117
+
118
+ # TestMu rebrand: also accept LT_* when TESTMU_* unset
119
+ if provider == CloudProvider.TESTMU:
120
+ if not username:
121
+ for alt in ("LT_USERNAME", "LAMBDATEST_USERNAME"):
122
+ username = os.environ.get(alt, "").strip()
123
+ if username:
124
+ user_env = alt
125
+ break
126
+ if not access_key:
127
+ for alt in ("LT_ACCESS_KEY", "LAMBDATEST_ACCESS_KEY"):
128
+ access_key = os.environ.get(alt, "").strip()
129
+ if access_key:
130
+ key_env = alt
131
+ break
132
+
133
+ if not username or not access_key:
134
+ raise ValueError(
135
+ f"Cloud credentials missing for {provider.value}. "
136
+ f"Export ${user_env} and ${key_env}"
137
+ + (
138
+ " (or LT_USERNAME / LT_ACCESS_KEY)."
139
+ if provider == CloudProvider.TESTMU
140
+ else "."
141
+ )
142
+ )
143
+ return CloudCredentials(
144
+ username=username,
145
+ access_key=access_key,
146
+ username_env=user_env,
147
+ access_key_env=key_env,
148
+ )
149
+
150
+
151
+ @dataclass
152
+ class CloudRunRequest:
153
+ provider: CloudProvider
154
+ platform: str # android | ios
155
+ flow_yaml: str
156
+ scripts: dict[str, str] = field(default_factory=dict)
157
+ devices: list[str] = field(default_factory=list)
158
+ app_path: str = ""
159
+ app_url: str = "" # bs://… or lt://…
160
+ project: str = "MobiFlow"
161
+ build_name: str = ""
162
+ real_mobile: bool = True
163
+ username_env: str = ""
164
+ access_key_env: str = ""
165
+ timeout_s: int = 1800
166
+ poll_interval_s: float = 15.0
167
+ local: bool = False # BrowserStack local testing flag
168
+ flow_name: str = "flow.yaml"
169
+
170
+
171
+ @dataclass
172
+ class CloudRunResult:
173
+ ok: bool
174
+ provider: str
175
+ build_id: str = ""
176
+ status: str = ""
177
+ dashboard_url: str = ""
178
+ app_url: str = ""
179
+ test_suite_url: str = ""
180
+ stdout: str = ""
181
+ stderr: str = ""
182
+ error: str | None = None
183
+ raw: dict[str, Any] = field(default_factory=dict)
184
+ media_urls: list[dict[str, str]] = field(default_factory=list)
185
+ media_files: list[str] = field(default_factory=list)
186
+ media_dir: str = ""
187
+ video_url: str = ""
188
+
189
+ def as_run_dict(self) -> dict[str, Any]:
190
+ video = self.video_url
191
+ if not video:
192
+ for item in self.media_urls:
193
+ if item.get("kind") == "video" and item.get("url"):
194
+ video = item["url"]
195
+ break
196
+ return {
197
+ "ok": self.ok,
198
+ "returncode": 0 if self.ok else 1,
199
+ "stdout": self.stdout,
200
+ "stderr": self.stderr or (self.error or ""),
201
+ "error": None if self.ok else (self.error or "cloud_run_failed"),
202
+ "provider": self.provider,
203
+ "build_id": self.build_id,
204
+ "status": self.status,
205
+ "dashboard_url": self.dashboard_url,
206
+ "app_url": self.app_url,
207
+ "test_suite_url": self.test_suite_url,
208
+ "raw": self.raw,
209
+ "media_urls": self.media_urls,
210
+ "media_files": self.media_files,
211
+ "media_dir": self.media_dir,
212
+ "video_url": video,
213
+ }
214
+
215
+
216
+ def zip_maestro_suite(
217
+ flow_yaml: str,
218
+ scripts: dict[str, str] | None = None,
219
+ *,
220
+ flow_name: str = "flow.yaml",
221
+ folder_name: str = "tests",
222
+ ) -> bytes:
223
+ """Zip Maestro flows for BrowserStack (parent folder required)."""
224
+ buf = io.BytesIO()
225
+ with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
226
+ zf.writestr(f"{folder_name}/{flow_name}", flow_yaml)
227
+ for rel, body in (scripts or {}).items():
228
+ # Keep scripts under the same parent folder
229
+ clean = rel.replace("\\", "/").lstrip("/")
230
+ zf.writestr(f"{folder_name}/{clean}", body)
231
+ return buf.getvalue()
232
+
233
+
234
+ def write_suite_dir(
235
+ root: Path,
236
+ flow_yaml: str,
237
+ scripts: dict[str, str] | None = None,
238
+ *,
239
+ flow_name: str = "flow.yaml",
240
+ ) -> Path:
241
+ """Write flow + scripts under root; return path to the flow file."""
242
+ root.mkdir(parents=True, exist_ok=True)
243
+ flow_path = root / flow_name
244
+ flow_path.write_text(flow_yaml, encoding="utf-8")
245
+ for rel, body in (scripts or {}).items():
246
+ sp = root / rel
247
+ sp.parent.mkdir(parents=True, exist_ok=True)
248
+ sp.write_text(body, encoding="utf-8")
249
+ return flow_path
250
+
251
+
252
+ def devices_from_config(
253
+ device_id: str | None,
254
+ *,
255
+ platform: str,
256
+ provider: CloudProvider,
257
+ ) -> list[str]:
258
+ """Resolve cloud device list from device_id (comma-separated OK)."""
259
+ raw = (device_id or "").strip()
260
+ if raw:
261
+ parts = [p.strip() for p in raw.split(",") if p.strip()]
262
+ if parts:
263
+ return parts
264
+ # Sensible defaults for smoke / first-run
265
+ if provider == CloudProvider.BROWSERSTACK:
266
+ if (platform or "").lower() == "ios":
267
+ return ["iPhone 15-17.0"]
268
+ return ["Google Pixel 7-13.0"]
269
+ # TestMu HyperExecute device strings
270
+ if (platform or "").lower() == "ios":
271
+ return ["iPhone 15"]
272
+ return ["Pixel 6-14"]
@@ -0,0 +1,330 @@
1
+ """BrowserStack App Automate Maestro REST client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from mobiflow.cloud.base import (
15
+ CloudCredentials,
16
+ CloudRunRequest,
17
+ CloudRunResult,
18
+ resolve_credentials,
19
+ zip_maestro_suite,
20
+ )
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ ProgressFn = Callable[[str], None] | None
25
+
26
+ API_BASE = "https://api-cloud.browserstack.com/app-automate"
27
+ UPLOAD_APP = f"{API_BASE}/upload"
28
+ UPLOAD_SUITE = f"{API_BASE}/maestro/v2/test-suite"
29
+ BUILD_ANDROID = f"{API_BASE}/maestro/v2/android/build"
30
+ BUILD_IOS = f"{API_BASE}/maestro/v2/ios/build"
31
+ BUILD_STATUS = f"{API_BASE}/maestro/v2/builds/{{build_id}}"
32
+
33
+ _TERMINAL_OK = {"passed", "completed", "success"}
34
+ _TERMINAL_FAIL = {
35
+ "failed",
36
+ "error",
37
+ "timeout",
38
+ "timedout",
39
+ "skipped",
40
+ "stopped",
41
+ "cancelled",
42
+ "canceled",
43
+ }
44
+
45
+
46
+ def _auth(creds: CloudCredentials) -> tuple[str, str]:
47
+ return (creds.username, creds.access_key)
48
+
49
+
50
+ def _dashboard_url(build_id: str) -> str:
51
+ return f"https://app-automate.browserstack.com/dashboard/v2/builds/{build_id}"
52
+
53
+
54
+ async def upload_app(
55
+ client: httpx.AsyncClient,
56
+ creds: CloudCredentials,
57
+ app_path: Path,
58
+ *,
59
+ custom_id: str = "mobiflow-app",
60
+ ) -> str:
61
+ if not app_path.is_file():
62
+ raise FileNotFoundError(f"App not found: {app_path}")
63
+ data = {"custom_id": custom_id}
64
+ files = {"file": (app_path.name, app_path.read_bytes())}
65
+ resp = await client.post(
66
+ UPLOAD_APP, auth=_auth(creds), data=data, files=files, timeout=600.0
67
+ )
68
+ resp.raise_for_status()
69
+ payload = resp.json()
70
+ url = payload.get("app_url") or payload.get("appUrl")
71
+ if not url:
72
+ raise RuntimeError(f"BrowserStack app upload missing app_url: {payload}")
73
+ return str(url)
74
+
75
+
76
+ async def upload_test_suite(
77
+ client: httpx.AsyncClient,
78
+ creds: CloudCredentials,
79
+ zip_bytes: bytes,
80
+ *,
81
+ custom_id: str = "mobiflow-suite",
82
+ ) -> str:
83
+ files = {"file": ("maestro_tests.zip", zip_bytes, "application/zip")}
84
+ data = {"custom_id": custom_id}
85
+ resp = await client.post(
86
+ UPLOAD_SUITE, auth=_auth(creds), data=data, files=files, timeout=300.0
87
+ )
88
+ resp.raise_for_status()
89
+ payload = resp.json()
90
+ url = (
91
+ payload.get("test_suite_url")
92
+ or payload.get("testSuiteUrl")
93
+ or payload.get("test_url")
94
+ )
95
+ if not url:
96
+ raise RuntimeError(f"BrowserStack suite upload missing test_suite_url: {payload}")
97
+ return str(url)
98
+
99
+
100
+ async def start_build(
101
+ client: httpx.AsyncClient,
102
+ creds: CloudCredentials,
103
+ *,
104
+ platform: str,
105
+ app_url: str,
106
+ test_suite_url: str,
107
+ devices: list[str],
108
+ project: str,
109
+ execute: list[str],
110
+ build_name: str = "",
111
+ local: bool = False,
112
+ ) -> str:
113
+ plat = (platform or "android").lower()
114
+ endpoint = BUILD_IOS if plat == "ios" else BUILD_ANDROID
115
+ body: dict[str, Any] = {
116
+ "app": app_url,
117
+ "testSuite": test_suite_url,
118
+ "devices": devices,
119
+ "project": project or "MobiFlow",
120
+ "execute": execute,
121
+ "deviceLogs": True,
122
+ "debugscreenshots": True,
123
+ }
124
+ if build_name:
125
+ body["customBuildName"] = build_name
126
+ if local:
127
+ body["local"] = "true"
128
+ resp = await client.post(
129
+ endpoint,
130
+ auth=_auth(creds),
131
+ headers={"Content-Type": "application/json"},
132
+ content=json.dumps(body),
133
+ timeout=120.0,
134
+ )
135
+ resp.raise_for_status()
136
+ payload = resp.json()
137
+ build_id = payload.get("build_id") or payload.get("buildId") or payload.get("id")
138
+ if not build_id:
139
+ raise RuntimeError(f"BrowserStack build start missing build_id: {payload}")
140
+ return str(build_id)
141
+
142
+
143
+ async def get_build_status(
144
+ client: httpx.AsyncClient,
145
+ creds: CloudCredentials,
146
+ build_id: str,
147
+ ) -> dict[str, Any]:
148
+ resp = await client.get(
149
+ BUILD_STATUS.format(build_id=build_id),
150
+ auth=_auth(creds),
151
+ timeout=60.0,
152
+ )
153
+ resp.raise_for_status()
154
+ return resp.json()
155
+
156
+
157
+ def _status_is_terminal(status: str) -> bool:
158
+ s = (status or "").strip().lower()
159
+ return s in _TERMINAL_OK or s in _TERMINAL_FAIL or s in {"done", "finished"}
160
+
161
+
162
+ def _status_ok(status: str) -> bool:
163
+ return (status or "").strip().lower() in _TERMINAL_OK | {"done", "finished"}
164
+
165
+
166
+ async def poll_build(
167
+ client: httpx.AsyncClient,
168
+ creds: CloudCredentials,
169
+ build_id: str,
170
+ *,
171
+ timeout_s: float,
172
+ poll_interval_s: float,
173
+ progress: ProgressFn = None,
174
+ ) -> dict[str, Any]:
175
+ loop = asyncio.get_running_loop()
176
+ deadline = loop.time() + timeout_s
177
+ last: dict[str, Any] = {}
178
+ while True:
179
+ last = await get_build_status(client, creds, build_id)
180
+ status = str(last.get("status") or "")
181
+ if progress:
182
+ progress(f"BrowserStack build {build_id[:12]}… status={status}")
183
+ if _status_is_terminal(status):
184
+ return last
185
+ if loop.time() >= deadline:
186
+ last["_mobiflow_error"] = "timeout"
187
+ return last
188
+ await asyncio.sleep(max(2.0, poll_interval_s))
189
+
190
+
191
+ async def run_browserstack(
192
+ request: CloudRunRequest,
193
+ *,
194
+ progress: ProgressFn = None,
195
+ artifact_dir: Path | None = None,
196
+ ) -> CloudRunResult:
197
+ creds = resolve_credentials(
198
+ request.provider,
199
+ username_env=request.username_env,
200
+ access_key_env=request.access_key_env,
201
+ )
202
+ devices = list(request.devices)
203
+ if not devices:
204
+ return CloudRunResult(
205
+ ok=False,
206
+ provider="browserstack",
207
+ error="no_devices",
208
+ stderr="device.device_id (cloud device name) is required for BrowserStack",
209
+ )
210
+
211
+ app_url = (request.app_url or "").strip()
212
+ flow_name = request.flow_name or "flow.yaml"
213
+
214
+ async with httpx.AsyncClient() as client:
215
+ if not app_url:
216
+ if not request.app_path:
217
+ return CloudRunResult(
218
+ ok=False,
219
+ provider="browserstack",
220
+ error="app_missing",
221
+ stderr=(
222
+ "Set device.app_path (.apk/.ipa) or device.app_url (bs://…) "
223
+ "for BrowserStack."
224
+ ),
225
+ )
226
+ if progress:
227
+ progress(f"Uploading app to BrowserStack: {request.app_path}")
228
+ app_url = await upload_app(client, creds, Path(request.app_path))
229
+ if progress:
230
+ progress(f"App uploaded → {app_url}")
231
+
232
+ if progress:
233
+ progress("Uploading Maestro test suite to BrowserStack…")
234
+ zip_bytes = zip_maestro_suite(
235
+ request.flow_yaml,
236
+ request.scripts,
237
+ flow_name=flow_name,
238
+ )
239
+ suite_url = await upload_test_suite(client, creds, zip_bytes)
240
+ if progress:
241
+ progress(f"Test suite uploaded → {suite_url}")
242
+
243
+ if progress:
244
+ progress(f"Starting BrowserStack Maestro build on {devices}…")
245
+ build_id = await start_build(
246
+ client,
247
+ creds,
248
+ platform=request.platform,
249
+ app_url=app_url,
250
+ test_suite_url=suite_url,
251
+ devices=devices,
252
+ project=request.project,
253
+ execute=[flow_name],
254
+ build_name=request.build_name,
255
+ local=request.local,
256
+ )
257
+ dash = _dashboard_url(build_id)
258
+ if progress:
259
+ progress(f"Build started id={build_id} · {dash}")
260
+
261
+ final = await poll_build(
262
+ client,
263
+ creds,
264
+ build_id,
265
+ timeout_s=float(request.timeout_s),
266
+ poll_interval_s=request.poll_interval_s,
267
+ progress=progress,
268
+ )
269
+
270
+ status = str(final.get("status") or "")
271
+ ok = _status_ok(status) and not final.get("_mobiflow_error")
272
+ err = None
273
+ if final.get("_mobiflow_error") == "timeout":
274
+ err = "timeout"
275
+ ok = False
276
+ elif not ok:
277
+ err = f"build_{status or 'failed'}"
278
+
279
+ summary = json.dumps(
280
+ {
281
+ "build_id": build_id,
282
+ "status": status,
283
+ "devices": final.get("devices"),
284
+ "dashboard": dash,
285
+ },
286
+ indent=2,
287
+ )
288
+ media_urls: list[dict[str, str]] = []
289
+ media_files: list[str] = []
290
+ media_dir = ""
291
+ video_url = ""
292
+ if artifact_dir is not None and build_id:
293
+ try:
294
+ from mobiflow.cloud.media import pull_browserstack_media
295
+
296
+ media_dest = Path(artifact_dir) / "cloud"
297
+ media_index = await pull_browserstack_media(
298
+ creds,
299
+ build_id,
300
+ final,
301
+ media_dest,
302
+ progress=progress,
303
+ )
304
+ media_urls = list(media_index.get("urls") or [])
305
+ media_files = list(media_index.get("files") or [])
306
+ media_dir = str(media_dest)
307
+ for item in media_urls:
308
+ if item.get("kind") == "video":
309
+ video_url = item.get("url") or ""
310
+ break
311
+ except Exception as exc: # noqa: BLE001
312
+ logger.warning("BrowserStack media pull failed: %s", exc)
313
+
314
+ return CloudRunResult(
315
+ ok=ok,
316
+ provider="browserstack",
317
+ build_id=build_id,
318
+ status=status,
319
+ dashboard_url=dash,
320
+ app_url=app_url,
321
+ test_suite_url=suite_url,
322
+ stdout=summary,
323
+ stderr="" if ok else summary,
324
+ error=err,
325
+ raw=final,
326
+ media_urls=media_urls,
327
+ media_files=media_files,
328
+ media_dir=media_dir,
329
+ video_url=video_url,
330
+ )