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