@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,538 @@
|
|
|
1
|
+
"""Load and validate mobiflow.config.yaml. Secrets come from env vars only.
|
|
2
|
+
|
|
3
|
+
User-facing config:
|
|
4
|
+
|
|
5
|
+
project / llm / stack / run / device
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
18
|
+
|
|
19
|
+
CONFIG_FILENAME = "mobiflow.config.yaml"
|
|
20
|
+
|
|
21
|
+
_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _validate_env_var_name(value: str, field: str) -> str:
|
|
25
|
+
name = value.strip()
|
|
26
|
+
looks_like_secret = (
|
|
27
|
+
not _ENV_NAME_RE.match(name)
|
|
28
|
+
or len(name) > 64
|
|
29
|
+
or (len(name) >= 20 and any(c.islower() for c in name))
|
|
30
|
+
)
|
|
31
|
+
if looks_like_secret:
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f"llm.{field} must be the NAME of an environment variable "
|
|
34
|
+
f"(e.g. AZURE_OPENAI_API_KEY), not a value. Got a {len(name)}-character "
|
|
35
|
+
"string that looks like a secret. Secrets never belong in "
|
|
36
|
+
f"{CONFIG_FILENAME} — export it in your shell instead."
|
|
37
|
+
)
|
|
38
|
+
return name
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _StrictLoader(yaml.SafeLoader):
|
|
42
|
+
"""SafeLoader that rejects duplicate mapping keys."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _no_duplicate_keys(loader: _StrictLoader, node: yaml.MappingNode, deep: bool = False):
|
|
46
|
+
seen: set[Any] = set()
|
|
47
|
+
for key_node, _ in node.value:
|
|
48
|
+
key = loader.construct_object(key_node, deep=deep)
|
|
49
|
+
if key in seen:
|
|
50
|
+
raise yaml.YAMLError(
|
|
51
|
+
f"Duplicate key {key!r} at line {key_node.start_mark.line + 1} "
|
|
52
|
+
f"of {CONFIG_FILENAME}."
|
|
53
|
+
)
|
|
54
|
+
seen.add(key)
|
|
55
|
+
return yaml.SafeLoader.construct_mapping(loader, node, deep=deep)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_StrictLoader.add_constructor(
|
|
59
|
+
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _no_duplicate_keys
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ProjectMode(str, Enum):
|
|
64
|
+
EXISTING = "existing"
|
|
65
|
+
LOCAL = "local"
|
|
66
|
+
NEW = "new"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class LlmConfig(BaseModel):
|
|
70
|
+
"""Selects catalog profiles for discovery (adaptive explore) and codegen (YAML)."""
|
|
71
|
+
|
|
72
|
+
catalog: str = "llm.json"
|
|
73
|
+
discovery: str | None = None # adaptive plan / device explore
|
|
74
|
+
codegen: str | None = None # Maestro YAML authoring
|
|
75
|
+
|
|
76
|
+
# Legacy inline (used when discovery/codegen unset)
|
|
77
|
+
provider: str = "openai"
|
|
78
|
+
model: str | None = None
|
|
79
|
+
api_key_env: str = "MOBIFLOW_LLM_API_KEY"
|
|
80
|
+
endpoint: str | None = None
|
|
81
|
+
azure_endpoint: str | None = None
|
|
82
|
+
azure_endpoint_env: str = "AZURE_OPENAI_ENDPOINT"
|
|
83
|
+
azure_api_version: str = "2025-03-01-preview"
|
|
84
|
+
|
|
85
|
+
@field_validator("api_key_env")
|
|
86
|
+
@classmethod
|
|
87
|
+
def _check_api_key_env(cls, v: str) -> str:
|
|
88
|
+
return _validate_env_var_name(v, "api_key_env")
|
|
89
|
+
|
|
90
|
+
@field_validator("azure_endpoint_env")
|
|
91
|
+
@classmethod
|
|
92
|
+
def _check_azure_endpoint_env(cls, v: str) -> str:
|
|
93
|
+
return _validate_env_var_name(v, "azure_endpoint_env")
|
|
94
|
+
|
|
95
|
+
@model_validator(mode="after")
|
|
96
|
+
def _normalize(self) -> LlmConfig:
|
|
97
|
+
if self.endpoint and not self.azure_endpoint:
|
|
98
|
+
self.azure_endpoint = self.endpoint
|
|
99
|
+
if self.azure_endpoint and not self.endpoint:
|
|
100
|
+
self.endpoint = self.azure_endpoint
|
|
101
|
+
if self.discovery and not self.codegen:
|
|
102
|
+
self.codegen = self.discovery
|
|
103
|
+
if self.codegen and not self.discovery:
|
|
104
|
+
self.discovery = self.codegen
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def uses_catalog(self) -> bool:
|
|
109
|
+
return bool(self.discovery or self.codegen)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class StackConfig(BaseModel):
|
|
113
|
+
tool: str = "maestro" # maestro
|
|
114
|
+
# yaml = YAML-only flows; yaml+js / javascript = Maestro YAML + JS (evalScript/runScript)
|
|
115
|
+
language: str = "yaml+js"
|
|
116
|
+
runner: str = "maestro" # maestro test
|
|
117
|
+
flow_dir: str = "flows"
|
|
118
|
+
cases_dir: str = "cases"
|
|
119
|
+
scripts_dir: str = "flows/scripts" # companion .js next to / under flows
|
|
120
|
+
|
|
121
|
+
def js_enabled(self) -> bool:
|
|
122
|
+
lang = (self.language or "yaml").strip().lower().replace(" ", "")
|
|
123
|
+
return lang in {"yaml+js", "yamljs", "js", "javascript", "maestro+js"}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class DeviceConfig(BaseModel):
|
|
127
|
+
"""Local or cloud device lab settings.
|
|
128
|
+
|
|
129
|
+
``provider``:
|
|
130
|
+
- ``local`` — adb / Android AVD / iOS Simulator (default)
|
|
131
|
+
- ``browserstack`` — BrowserStack App Automate Maestro REST API
|
|
132
|
+
- ``testmu`` — TestMu AI (formerly LambdaTest) HyperExecute Maestro
|
|
133
|
+
- ``maestro`` — first-party Maestro Cloud (`maestro cloud`)
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
provider: str = "local" # local | browserstack | testmu | maestro
|
|
137
|
+
platform: str = "android" # android | ios
|
|
138
|
+
app_id: str = ""
|
|
139
|
+
device_id: str | None = None # local serial/UDID OR cloud device name
|
|
140
|
+
auto_start: bool = True # local only: start AVD / iOS Simulator if none online
|
|
141
|
+
boot_timeout_s: int = 120 # wait for emulator/simulator boot
|
|
142
|
+
# Prefer `maestro start-device` / `maestro list-devices` before adb/simctl
|
|
143
|
+
use_maestro_cli: bool = True
|
|
144
|
+
# Optional Maestro start-device model/os (e.g. iPhone-16, pixel_7, iOS-18-2)
|
|
145
|
+
device_model: str = ""
|
|
146
|
+
device_os: str = ""
|
|
147
|
+
device_locale: str = ""
|
|
148
|
+
|
|
149
|
+
# Cloud labs (BrowserStack / TestMu / Maestro Cloud)
|
|
150
|
+
app_path: str = "" # local .apk / .ipa / .aab to upload
|
|
151
|
+
app_url: str = "" # already-uploaded bs://… or lt://…
|
|
152
|
+
cloud_project: str = "MobiFlow"
|
|
153
|
+
cloud_build_name: str = ""
|
|
154
|
+
real_mobile: bool = True # TestMu: real device vs virtual
|
|
155
|
+
username_env: str = "" # override default credential env var names
|
|
156
|
+
access_key_env: str = ""
|
|
157
|
+
cloud_timeout_s: int = 1800 # cloud build/job timeout
|
|
158
|
+
poll_interval_s: float = 15.0 # BrowserStack status poll
|
|
159
|
+
browserstack_local: bool = False # enable BS local testing flag
|
|
160
|
+
|
|
161
|
+
@field_validator("provider")
|
|
162
|
+
@classmethod
|
|
163
|
+
def _check_provider(cls, v: str) -> str:
|
|
164
|
+
from mobiflow.cloud.base import normalize_provider
|
|
165
|
+
|
|
166
|
+
return normalize_provider(v).value
|
|
167
|
+
|
|
168
|
+
def is_cloud(self) -> bool:
|
|
169
|
+
from mobiflow.cloud.base import is_cloud_provider
|
|
170
|
+
|
|
171
|
+
return is_cloud_provider(self.provider)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class RunConfig(BaseModel):
|
|
175
|
+
heal: int = 2 # YAML repair attempts after failed run
|
|
176
|
+
adaptive: bool = True # hierarchy-aware heal / explore on live device
|
|
177
|
+
explore: bool = True # explore app with discovery LLM before codegen
|
|
178
|
+
explore_steps: int = 5 # max live explore actions before codegen
|
|
179
|
+
timeout_s: int = 180 # maestro test timeout
|
|
180
|
+
save_artifacts: bool = True
|
|
181
|
+
# Local: run `maestro record --local` after test to capture MP4 (cloud labs use lab video)
|
|
182
|
+
video: bool = True
|
|
183
|
+
# Reporting: junit | html (comma-string or list). Empty / none disables.
|
|
184
|
+
reports: list[str] = Field(default_factory=lambda: ["junit", "html"])
|
|
185
|
+
report_dir: str = ".mobiflow/reports" # relative to project path
|
|
186
|
+
# Completeness foundations (suite / flake / reuse / secrets — wired in later phases)
|
|
187
|
+
retries: int = 0 # re-run same YAML on failure before heal
|
|
188
|
+
reuse_flow: bool = False # prefer flows/<case>.yaml over LLM codegen
|
|
189
|
+
incremental: bool = False # classify guidance; gap-explore appended steps
|
|
190
|
+
extend_explore: bool = False # full explore + extend codegen from prior YAML
|
|
191
|
+
fail_fast: bool = False # suite: stop after first failing case
|
|
192
|
+
jobs: int = 1 # suite parallelism (1 = sequential)
|
|
193
|
+
env: dict[str, str] = Field(default_factory=dict) # Maestro --env KEY=VALUE
|
|
194
|
+
# Lifecycle: install (local adb/simctl) and/or clear (Maestro clearState)
|
|
195
|
+
preflight: list[str] = Field(default_factory=list)
|
|
196
|
+
# Maestro workspace / suite filters (passed to `maestro test`)
|
|
197
|
+
include_tags: list[str] = Field(default_factory=list)
|
|
198
|
+
exclude_tags: list[str] = Field(default_factory=list)
|
|
199
|
+
maestro_config: str = "" # path to Maestro config.yaml (optional)
|
|
200
|
+
|
|
201
|
+
@field_validator("reports", mode="before")
|
|
202
|
+
@classmethod
|
|
203
|
+
def _coerce_reports(cls, v: Any) -> list[str]:
|
|
204
|
+
from mobiflow.reporting import normalize_report_formats
|
|
205
|
+
|
|
206
|
+
return normalize_report_formats(v)
|
|
207
|
+
|
|
208
|
+
@field_validator("preflight", mode="before")
|
|
209
|
+
@classmethod
|
|
210
|
+
def _coerce_preflight(cls, v: Any) -> list[str]:
|
|
211
|
+
from mobiflow.maestro.lifecycle import normalize_preflight
|
|
212
|
+
|
|
213
|
+
return normalize_preflight(v)
|
|
214
|
+
|
|
215
|
+
@field_validator("explore_steps")
|
|
216
|
+
@classmethod
|
|
217
|
+
def _clamp_explore_steps(cls, v: int) -> int:
|
|
218
|
+
try:
|
|
219
|
+
n = int(v)
|
|
220
|
+
except (TypeError, ValueError):
|
|
221
|
+
return 5
|
|
222
|
+
return max(1, min(n, 12))
|
|
223
|
+
|
|
224
|
+
@field_validator("retries")
|
|
225
|
+
@classmethod
|
|
226
|
+
def _clamp_retries(cls, v: int) -> int:
|
|
227
|
+
try:
|
|
228
|
+
n = int(v)
|
|
229
|
+
except (TypeError, ValueError):
|
|
230
|
+
return 0
|
|
231
|
+
return max(0, min(n, 10))
|
|
232
|
+
|
|
233
|
+
@field_validator("jobs")
|
|
234
|
+
@classmethod
|
|
235
|
+
def _clamp_jobs(cls, v: int) -> int:
|
|
236
|
+
try:
|
|
237
|
+
n = int(v)
|
|
238
|
+
except (TypeError, ValueError):
|
|
239
|
+
return 1
|
|
240
|
+
return max(1, min(n, 32))
|
|
241
|
+
|
|
242
|
+
@field_validator("env", mode="before")
|
|
243
|
+
@classmethod
|
|
244
|
+
def _coerce_env(cls, v: Any) -> dict[str, str]:
|
|
245
|
+
if v is None:
|
|
246
|
+
return {}
|
|
247
|
+
if not isinstance(v, dict):
|
|
248
|
+
raise ValueError("run.env must be a mapping of KEY: VALUE")
|
|
249
|
+
out: dict[str, str] = {}
|
|
250
|
+
for key, val in v.items():
|
|
251
|
+
k = str(key).strip()
|
|
252
|
+
if not k:
|
|
253
|
+
continue
|
|
254
|
+
out[k] = "" if val is None else str(val)
|
|
255
|
+
return out
|
|
256
|
+
|
|
257
|
+
@field_validator("include_tags", "exclude_tags", mode="before")
|
|
258
|
+
@classmethod
|
|
259
|
+
def _coerce_tags(cls, v: Any) -> list[str]:
|
|
260
|
+
if v is None or v == "":
|
|
261
|
+
return []
|
|
262
|
+
if isinstance(v, str):
|
|
263
|
+
parts = [p.strip() for p in v.replace(";", ",").split(",")]
|
|
264
|
+
return [p for p in parts if p]
|
|
265
|
+
if isinstance(v, (list, tuple, set)):
|
|
266
|
+
return [str(x).strip() for x in v if str(x).strip()]
|
|
267
|
+
return [str(v).strip()] if str(v).strip() else []
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class ProjectConfig(BaseModel):
|
|
271
|
+
mode: ProjectMode = ProjectMode.LOCAL
|
|
272
|
+
path: str = "."
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class MobiflowConfig(BaseModel):
|
|
276
|
+
project: ProjectConfig = Field(default_factory=ProjectConfig)
|
|
277
|
+
llm: LlmConfig = Field(default_factory=LlmConfig)
|
|
278
|
+
stack: StackConfig = Field(default_factory=StackConfig)
|
|
279
|
+
device: DeviceConfig = Field(default_factory=DeviceConfig)
|
|
280
|
+
run: RunConfig = Field(default_factory=RunConfig)
|
|
281
|
+
|
|
282
|
+
def repo_path(self) -> Path:
|
|
283
|
+
return Path(self.project.path).expanduser().resolve()
|
|
284
|
+
|
|
285
|
+
def flow_dir_path(self) -> Path:
|
|
286
|
+
return (self.repo_path() / self.stack.flow_dir).resolve()
|
|
287
|
+
|
|
288
|
+
def scripts_dir_path(self) -> Path:
|
|
289
|
+
raw = (self.stack.scripts_dir or "flows/scripts").strip() or "flows/scripts"
|
|
290
|
+
p = Path(raw).expanduser()
|
|
291
|
+
if p.is_absolute():
|
|
292
|
+
return p.resolve()
|
|
293
|
+
return (self.repo_path() / p).resolve()
|
|
294
|
+
|
|
295
|
+
def cases_dir_path(self) -> Path:
|
|
296
|
+
return (self.repo_path() / self.stack.cases_dir).resolve()
|
|
297
|
+
|
|
298
|
+
def artifacts_dir(self) -> Path:
|
|
299
|
+
return (self.repo_path() / ".mobiflow").resolve()
|
|
300
|
+
|
|
301
|
+
def report_dir_path(self) -> Path:
|
|
302
|
+
raw = (self.run.report_dir or ".mobiflow/reports").strip() or ".mobiflow/reports"
|
|
303
|
+
p = Path(raw).expanduser()
|
|
304
|
+
if p.is_absolute():
|
|
305
|
+
return p.resolve()
|
|
306
|
+
return (self.repo_path() / p).resolve()
|
|
307
|
+
|
|
308
|
+
def catalog_file(self) -> Path:
|
|
309
|
+
name = self.llm.catalog or "llm.json"
|
|
310
|
+
return self.repo_path() / name
|
|
311
|
+
|
|
312
|
+
def load_catalog(self):
|
|
313
|
+
from mobiflow.llm_catalog import ModelEntry, load_catalog
|
|
314
|
+
|
|
315
|
+
path = self.catalog_file()
|
|
316
|
+
if path.exists():
|
|
317
|
+
return load_catalog(self.repo_path(), filename=path.name)
|
|
318
|
+
# synthesize one-entry catalog from legacy inline
|
|
319
|
+
from mobiflow.llm_catalog import LlmCatalog
|
|
320
|
+
|
|
321
|
+
entry = ModelEntry(
|
|
322
|
+
provider=self.llm.provider,
|
|
323
|
+
model=self.llm.model or "gpt-4o",
|
|
324
|
+
api_key_env=self.llm.api_key_env,
|
|
325
|
+
endpoint=self.llm.endpoint or self.llm.azure_endpoint,
|
|
326
|
+
endpoint_env=self.llm.azure_endpoint_env,
|
|
327
|
+
api_version=self.llm.azure_api_version,
|
|
328
|
+
)
|
|
329
|
+
return LlmCatalog(models={"default": entry})
|
|
330
|
+
|
|
331
|
+
def discovery_profile(self):
|
|
332
|
+
from mobiflow.llm_catalog import ModelEntry
|
|
333
|
+
|
|
334
|
+
cat = self.load_catalog()
|
|
335
|
+
name = self.llm.discovery
|
|
336
|
+
if name:
|
|
337
|
+
return cat.get(name)
|
|
338
|
+
if "default" in cat.models:
|
|
339
|
+
return cat.get("default")
|
|
340
|
+
if cat.models:
|
|
341
|
+
return cat.get(next(iter(cat.models)))
|
|
342
|
+
return ModelEntry(
|
|
343
|
+
provider=self.llm.provider,
|
|
344
|
+
model=self.llm.model or "gpt-4o",
|
|
345
|
+
api_key_env=self.llm.api_key_env,
|
|
346
|
+
endpoint=self.llm.endpoint or self.llm.azure_endpoint,
|
|
347
|
+
endpoint_env=self.llm.azure_endpoint_env,
|
|
348
|
+
api_version=self.llm.azure_api_version,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def codegen_profile(self):
|
|
352
|
+
cat = self.load_catalog()
|
|
353
|
+
name = self.llm.codegen or self.llm.discovery
|
|
354
|
+
if name:
|
|
355
|
+
return cat.get(name)
|
|
356
|
+
return self.discovery_profile()
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def config_path(repo: Path | str | None = None) -> Path:
|
|
360
|
+
base = Path(repo).expanduser().resolve() if repo else Path.cwd()
|
|
361
|
+
return base / CONFIG_FILENAME
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def load_config(repo: Path | str | None = None) -> MobiflowConfig:
|
|
365
|
+
path = config_path(repo)
|
|
366
|
+
if not path.exists():
|
|
367
|
+
cwd_path = Path.cwd() / CONFIG_FILENAME
|
|
368
|
+
if repo is None and cwd_path.exists():
|
|
369
|
+
path = cwd_path
|
|
370
|
+
else:
|
|
371
|
+
raise FileNotFoundError(
|
|
372
|
+
f"No {CONFIG_FILENAME} found at {path}. Run `mobiflow init` first."
|
|
373
|
+
)
|
|
374
|
+
data = yaml.load(path.read_text(), Loader=_StrictLoader) or {}
|
|
375
|
+
return MobiflowConfig.model_validate(data)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def render_simple_config(config: MobiflowConfig) -> str:
|
|
379
|
+
llm = config.llm
|
|
380
|
+
stack = config.stack
|
|
381
|
+
run = config.run
|
|
382
|
+
device = config.device
|
|
383
|
+
discovery = llm.discovery or "default"
|
|
384
|
+
codegen = llm.codegen or discovery
|
|
385
|
+
empty = '""'
|
|
386
|
+
|
|
387
|
+
lines = [
|
|
388
|
+
"# mobiflow.config.yaml — pick models from llm.json",
|
|
389
|
+
"# Secrets stay in env vars (never paste API keys here).",
|
|
390
|
+
"# Docs: MOBIFLOW.md",
|
|
391
|
+
"",
|
|
392
|
+
"project:",
|
|
393
|
+
f" mode: {config.project.mode.value}",
|
|
394
|
+
f" path: {config.project.path}",
|
|
395
|
+
"",
|
|
396
|
+
"llm:",
|
|
397
|
+
f" catalog: {llm.catalog or 'llm.json'} # model catalog file",
|
|
398
|
+
f" discovery: {discovery} # adaptive explore / plan on device",
|
|
399
|
+
f" codegen: {codegen} # Maestro YAML authoring",
|
|
400
|
+
"",
|
|
401
|
+
"stack:",
|
|
402
|
+
f" tool: {stack.tool}",
|
|
403
|
+
f" language: {stack.language} # yaml | yaml+js (Maestro JS via evalScript/runScript)",
|
|
404
|
+
f" runner: {stack.runner}",
|
|
405
|
+
f" flow_dir: {stack.flow_dir}",
|
|
406
|
+
f" scripts_dir: {stack.scripts_dir} # companion .js files",
|
|
407
|
+
f" cases_dir: {stack.cases_dir}",
|
|
408
|
+
"",
|
|
409
|
+
"device:",
|
|
410
|
+
f" provider: {device.provider} # local | browserstack | testmu | maestro",
|
|
411
|
+
f" platform: {device.platform}",
|
|
412
|
+
f" app_id: {device.app_id or empty} # e.g. org.wikipedia / org.wikimedia.wikipedia",
|
|
413
|
+
f" device_id: {device.device_id or empty} # local serial/UDID OR cloud device (e.g. Google Pixel 7-13.0)",
|
|
414
|
+
f" auto_start: {str(device.auto_start).lower()} # local only: start AVD / Xcode sim if none online",
|
|
415
|
+
f" use_maestro_cli: {str(device.use_maestro_cli).lower()} # prefer maestro start-device",
|
|
416
|
+
f" device_model: {device.device_model or empty}",
|
|
417
|
+
f" device_os: {device.device_os or empty}",
|
|
418
|
+
f" device_locale: {device.device_locale or empty}",
|
|
419
|
+
f" boot_timeout_s: {device.boot_timeout_s}",
|
|
420
|
+
f" app_path: {device.app_path or empty} # cloud: path to .apk / .ipa to upload",
|
|
421
|
+
f" app_url: {device.app_url or empty} # cloud: existing bs://… or lt://… (skip upload)",
|
|
422
|
+
f" cloud_project: {device.cloud_project or 'MobiFlow'}",
|
|
423
|
+
f" cloud_build_name: {device.cloud_build_name or empty}",
|
|
424
|
+
f" real_mobile: {str(device.real_mobile).lower()} # testmu: real device vs emulator/simulator",
|
|
425
|
+
f" username_env: {device.username_env or empty} # optional override (defaults by provider)",
|
|
426
|
+
f" access_key_env: {device.access_key_env or empty}",
|
|
427
|
+
f" cloud_timeout_s: {device.cloud_timeout_s}",
|
|
428
|
+
"",
|
|
429
|
+
"run:",
|
|
430
|
+
f" heal: {run.heal} # YAML repair attempts (0 = off)",
|
|
431
|
+
f" adaptive: {str(run.adaptive).lower()} # hierarchy-aware heal on live device",
|
|
432
|
+
f" explore: {str(run.explore).lower()} # discovery LLM explores app before codegen",
|
|
433
|
+
f" explore_steps: {run.explore_steps} # max live explore actions",
|
|
434
|
+
f" timeout_s: {run.timeout_s}",
|
|
435
|
+
f" save_artifacts: {str(run.save_artifacts).lower()}",
|
|
436
|
+
f" video: {str(run.video).lower()} # local: maestro record --local after test",
|
|
437
|
+
f" reports: [{', '.join(run.reports) if run.reports else ''}] # junit, html — empty disables",
|
|
438
|
+
f" report_dir: {run.report_dir}",
|
|
439
|
+
f" retries: {run.retries} # re-run same YAML before heal (flake control)",
|
|
440
|
+
f" reuse_flow: {str(run.reuse_flow).lower()} # use flows/<case>.yaml instead of LLM",
|
|
441
|
+
f" incremental: {str(run.incremental).lower()} # gap-explore appended numbered steps",
|
|
442
|
+
f" extend_explore: {str(run.extend_explore).lower()} # seed codegen from prior YAML",
|
|
443
|
+
f" fail_fast: {str(run.fail_fast).lower()} # suite: stop on first failure",
|
|
444
|
+
f" jobs: {run.jobs} # suite parallelism (1 = sequential)",
|
|
445
|
+
f" preflight: [{', '.join(run.preflight) if run.preflight else ''}] # install, clear",
|
|
446
|
+
f" include_tags: [{', '.join(run.include_tags) if run.include_tags else ''}]",
|
|
447
|
+
f" exclude_tags: [{', '.join(run.exclude_tags) if run.exclude_tags else ''}]",
|
|
448
|
+
f" maestro_config: {run.maestro_config or empty}",
|
|
449
|
+
"",
|
|
450
|
+
"# Edit llm.json to add Azure / OpenAI / Anthropic / Google models.",
|
|
451
|
+
"# Then change discovery: / codegen: above to the profile ids you want.",
|
|
452
|
+
"",
|
|
453
|
+
]
|
|
454
|
+
return "\n".join(lines)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def save_config(config: MobiflowConfig, repo: Path | str | None = None) -> Path:
|
|
458
|
+
path = config_path(repo or config.repo_path())
|
|
459
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
460
|
+
path.write_text(render_simple_config(config), encoding="utf-8")
|
|
461
|
+
return path
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def find_config(start: Path | None = None) -> Path | None:
|
|
465
|
+
cur = (start or Path.cwd()).resolve()
|
|
466
|
+
for candidate in [cur, *cur.parents]:
|
|
467
|
+
p = candidate / CONFIG_FILENAME
|
|
468
|
+
if p.exists():
|
|
469
|
+
return p
|
|
470
|
+
return None
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def config_warnings(config: MobiflowConfig) -> list[str]:
|
|
474
|
+
out: list[str] = []
|
|
475
|
+
try:
|
|
476
|
+
disc = config.discovery_profile()
|
|
477
|
+
if not os.environ.get(disc.api_key_env):
|
|
478
|
+
out.append(
|
|
479
|
+
f"discovery: ${disc.api_key_env} is not set ({disc.display_name})."
|
|
480
|
+
)
|
|
481
|
+
if disc.provider.lower().startswith("azure"):
|
|
482
|
+
if not (disc.endpoint or os.environ.get(disc.endpoint_env)):
|
|
483
|
+
out.append(
|
|
484
|
+
f"discovery Azure endpoint missing "
|
|
485
|
+
f"(llm.json endpoint or ${disc.endpoint_env})."
|
|
486
|
+
)
|
|
487
|
+
except Exception as e: # noqa: BLE001
|
|
488
|
+
out.append(f"discovery LLM: {e}")
|
|
489
|
+
|
|
490
|
+
try:
|
|
491
|
+
code = config.codegen_profile()
|
|
492
|
+
if code.api_key_env != getattr(config.discovery_profile(), "api_key_env", None):
|
|
493
|
+
if not os.environ.get(code.api_key_env):
|
|
494
|
+
out.append(
|
|
495
|
+
f"codegen: ${code.api_key_env} is not set ({code.display_name})."
|
|
496
|
+
)
|
|
497
|
+
except Exception as e: # noqa: BLE001
|
|
498
|
+
out.append(f"codegen LLM: {e}")
|
|
499
|
+
|
|
500
|
+
if not config.catalog_file().exists() and not config.llm.uses_catalog:
|
|
501
|
+
out.append(
|
|
502
|
+
f"No {config.llm.catalog} found — using legacy inline llm settings. "
|
|
503
|
+
"Run mobiflow init or add llm.json for multi-model support."
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
if config.device.is_cloud():
|
|
507
|
+
try:
|
|
508
|
+
from mobiflow.cloud import cloud_readiness
|
|
509
|
+
|
|
510
|
+
ready = cloud_readiness(config.device)
|
|
511
|
+
if not ready.get("ready"):
|
|
512
|
+
out.append(ready.get("message") or "Cloud device lab not ready.")
|
|
513
|
+
except Exception as e: # noqa: BLE001
|
|
514
|
+
out.append(f"cloud device: {e}")
|
|
515
|
+
return out
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def effective_config_dict(config: MobiflowConfig) -> dict[str, Any]:
|
|
519
|
+
data: dict[str, Any] = {
|
|
520
|
+
"project": config.project.model_dump(mode="json"),
|
|
521
|
+
"llm": {
|
|
522
|
+
"catalog": config.llm.catalog,
|
|
523
|
+
"discovery": config.llm.discovery,
|
|
524
|
+
"codegen": config.llm.codegen,
|
|
525
|
+
},
|
|
526
|
+
"stack": config.stack.model_dump(mode="json"),
|
|
527
|
+
"device": config.device.model_dump(mode="json"),
|
|
528
|
+
"run": config.run.model_dump(mode="json"),
|
|
529
|
+
}
|
|
530
|
+
try:
|
|
531
|
+
data["llm"]["discovery_profile"] = config.discovery_profile().to_public_dict()
|
|
532
|
+
except Exception as e: # noqa: BLE001
|
|
533
|
+
data["llm"]["discovery_error"] = str(e)
|
|
534
|
+
try:
|
|
535
|
+
data["llm"]["codegen_profile"] = config.codegen_profile().to_public_dict()
|
|
536
|
+
except Exception as e: # noqa: BLE001
|
|
537
|
+
data["llm"]["codegen_error"] = str(e)
|
|
538
|
+
return data
|