@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,263 @@
|
|
|
1
|
+
"""Suite runner: discover cases, execute (optionally parallel), aggregate reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from mobiflow.cases import TestCase, discover_cases
|
|
17
|
+
from mobiflow.config import MobiflowConfig
|
|
18
|
+
from mobiflow.pipeline import run_pipeline
|
|
19
|
+
from mobiflow.reporting import ReportCase, write_suite_reports
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class SuiteResult:
|
|
27
|
+
name: str
|
|
28
|
+
cases: list[ReportCase] = field(default_factory=list)
|
|
29
|
+
started_at: str = ""
|
|
30
|
+
duration_s: float = 0.0
|
|
31
|
+
reports: dict[str, str] = field(default_factory=dict)
|
|
32
|
+
suite_dir: str = ""
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def total(self) -> int:
|
|
36
|
+
return len(self.cases)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def passed(self) -> int:
|
|
40
|
+
return sum(1 for c in self.cases if c.success)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def failed(self) -> int:
|
|
44
|
+
return sum(1 for c in self.cases if not c.success)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def success(self) -> bool:
|
|
48
|
+
return self.total > 0 and self.failed == 0
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict[str, Any]:
|
|
51
|
+
return {
|
|
52
|
+
"name": self.name,
|
|
53
|
+
"success": self.success,
|
|
54
|
+
"total": self.total,
|
|
55
|
+
"passed": self.passed,
|
|
56
|
+
"failed": self.failed,
|
|
57
|
+
"duration_s": self.duration_s,
|
|
58
|
+
"started_at": self.started_at,
|
|
59
|
+
"suite_dir": self.suite_dir,
|
|
60
|
+
"reports": self.reports,
|
|
61
|
+
"cases": [
|
|
62
|
+
{
|
|
63
|
+
"name": c.name,
|
|
64
|
+
"success": c.success,
|
|
65
|
+
"summary": c.summary,
|
|
66
|
+
"error": c.error,
|
|
67
|
+
"duration_s": c.duration_s,
|
|
68
|
+
"provider": c.provider,
|
|
69
|
+
"platform": c.platform,
|
|
70
|
+
"device_id": c.device_id,
|
|
71
|
+
"artifact_dir": c.artifact_dir,
|
|
72
|
+
"flow_path": c.flow_path,
|
|
73
|
+
"dashboard_url": c.dashboard_url,
|
|
74
|
+
}
|
|
75
|
+
for c in self.cases
|
|
76
|
+
],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _result_to_report_case(
|
|
81
|
+
case: TestCase,
|
|
82
|
+
result: dict[str, Any],
|
|
83
|
+
*,
|
|
84
|
+
provider: str,
|
|
85
|
+
platform: str,
|
|
86
|
+
device_id: str | None,
|
|
87
|
+
duration_s: float,
|
|
88
|
+
started_at: str,
|
|
89
|
+
) -> ReportCase:
|
|
90
|
+
run_meta = result.get("run") or {}
|
|
91
|
+
return ReportCase(
|
|
92
|
+
name=case.name,
|
|
93
|
+
success=bool(result.get("success")),
|
|
94
|
+
summary=str(result.get("summary") or ""),
|
|
95
|
+
error=str(result.get("error") or ""),
|
|
96
|
+
task=case.explore_task(),
|
|
97
|
+
platform=str(result.get("platform") or platform or ""),
|
|
98
|
+
provider=str(result.get("provider") or provider),
|
|
99
|
+
device_id=str(result.get("device_id") or device_id or ""),
|
|
100
|
+
duration_s=float(result.get("duration_s") or duration_s),
|
|
101
|
+
flow_path=str(result.get("flow_path") or ""),
|
|
102
|
+
dashboard_url=str(run_meta.get("dashboard_url") or ""),
|
|
103
|
+
build_id=str(run_meta.get("build_id") or ""),
|
|
104
|
+
stdout=str(run_meta.get("stdout") or ""),
|
|
105
|
+
stderr=str(run_meta.get("stderr") or ""),
|
|
106
|
+
logs=list(result.get("logs") or []),
|
|
107
|
+
synthesis_only=bool(result.get("synthesis_only")),
|
|
108
|
+
screenshot_paths=list(result.get("screenshots") or []),
|
|
109
|
+
artifact_dir=str(result.get("artifact_dir") or ""),
|
|
110
|
+
started_at=started_at,
|
|
111
|
+
video_url=str(run_meta.get("video_url") or ""),
|
|
112
|
+
explore_usage=dict(result.get("explore_usage") or {}),
|
|
113
|
+
codegen_usage=dict(result.get("codegen_usage") or {}),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def run_suite(
|
|
118
|
+
target: Path | str,
|
|
119
|
+
cfg: MobiflowConfig,
|
|
120
|
+
*,
|
|
121
|
+
tags: list[str] | None = None,
|
|
122
|
+
gen_only: bool = False,
|
|
123
|
+
device_id: str | None = None,
|
|
124
|
+
no_heal: bool = False,
|
|
125
|
+
fail_fast: bool | None = None,
|
|
126
|
+
suite_name: str | None = None,
|
|
127
|
+
reuse_flow: bool | None = None,
|
|
128
|
+
incremental: bool | None = None,
|
|
129
|
+
extend_explore: bool | None = None,
|
|
130
|
+
) -> SuiteResult:
|
|
131
|
+
"""Discover and run cases under ``target`` (file or directory)."""
|
|
132
|
+
path = Path(target).expanduser().resolve()
|
|
133
|
+
cases = discover_cases(path, tags=tags)
|
|
134
|
+
name = suite_name or (path.name if path.is_dir() else path.stem)
|
|
135
|
+
started_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
136
|
+
stamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
|
137
|
+
stop_on_fail = cfg.run.fail_fast if fail_fast is None else fail_fast
|
|
138
|
+
|
|
139
|
+
result = SuiteResult(name=name, started_at=started_at)
|
|
140
|
+
if not cases:
|
|
141
|
+
console.print(
|
|
142
|
+
f"[yellow]No cases matched[/yellow] path={path} "
|
|
143
|
+
f"tags={tags or '(any)'}"
|
|
144
|
+
)
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
jobs = max(1, int(cfg.run.jobs or 1))
|
|
148
|
+
if jobs > 1 and stop_on_fail:
|
|
149
|
+
console.print(
|
|
150
|
+
"[yellow]fail_fast is ignored when run.jobs > 1 "
|
|
151
|
+
"(all in-flight cases finish).[/yellow]"
|
|
152
|
+
)
|
|
153
|
+
console.print(
|
|
154
|
+
f"[bold]Suite[/bold] {name} cases={len(cases)} "
|
|
155
|
+
f"tags={','.join(tags) if tags else '(any)'} "
|
|
156
|
+
f"fail_fast={str(stop_on_fail).lower()} jobs={jobs}"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
t0 = time.monotonic()
|
|
160
|
+
provider = cfg.device.provider or "local"
|
|
161
|
+
|
|
162
|
+
def _run_one(case: TestCase) -> ReportCase:
|
|
163
|
+
case_started = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
164
|
+
case_t0 = time.monotonic()
|
|
165
|
+
try:
|
|
166
|
+
case_path = case.source_path
|
|
167
|
+
if case_path is None:
|
|
168
|
+
raise FileNotFoundError(f"Case {case.name} has no source_path")
|
|
169
|
+
raw = run_pipeline(
|
|
170
|
+
case_path,
|
|
171
|
+
cfg,
|
|
172
|
+
gen_only=gen_only,
|
|
173
|
+
device_id=device_id,
|
|
174
|
+
no_heal=no_heal,
|
|
175
|
+
reuse_flow=reuse_flow,
|
|
176
|
+
incremental=incremental,
|
|
177
|
+
extend_explore=extend_explore,
|
|
178
|
+
)
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
logger.exception("Suite case failed: %s", case.name)
|
|
181
|
+
raw = {
|
|
182
|
+
"success": False,
|
|
183
|
+
"summary": f"suite error: {exc}",
|
|
184
|
+
"error": str(exc),
|
|
185
|
+
"logs": [],
|
|
186
|
+
}
|
|
187
|
+
case_dur = time.monotonic() - case_t0
|
|
188
|
+
if not raw.get("flow_path") and raw.get("flow_yaml"):
|
|
189
|
+
raw["flow_path"] = str(cfg.flow_dir_path() / f"{case.name}.yaml")
|
|
190
|
+
return _result_to_report_case(
|
|
191
|
+
case,
|
|
192
|
+
raw,
|
|
193
|
+
provider=provider,
|
|
194
|
+
platform=case.platform or cfg.device.platform,
|
|
195
|
+
device_id=device_id or case.device_id or cfg.device.device_id,
|
|
196
|
+
duration_s=case_dur,
|
|
197
|
+
started_at=case_started,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if jobs == 1:
|
|
201
|
+
for i, case in enumerate(cases, 1):
|
|
202
|
+
console.print(
|
|
203
|
+
f"\n[bold cyan][{i}/{len(cases)}][/bold cyan] {case.name}"
|
|
204
|
+
+ (f" @{'/'.join(case.tags)}" if case.tags else "")
|
|
205
|
+
)
|
|
206
|
+
report_case = _run_one(case)
|
|
207
|
+
result.cases.append(report_case)
|
|
208
|
+
if stop_on_fail and not report_case.success:
|
|
209
|
+
console.print("[yellow]fail_fast: stopping suite[/yellow]")
|
|
210
|
+
break
|
|
211
|
+
else:
|
|
212
|
+
console.print(f"[dim]Running up to {jobs} cases in parallel…[/dim]")
|
|
213
|
+
ordered: list[ReportCase | None] = [None] * len(cases)
|
|
214
|
+
with ThreadPoolExecutor(max_workers=jobs) as pool:
|
|
215
|
+
futures = {
|
|
216
|
+
pool.submit(_run_one, case): idx for idx, case in enumerate(cases)
|
|
217
|
+
}
|
|
218
|
+
for fut in as_completed(futures):
|
|
219
|
+
idx = futures[fut]
|
|
220
|
+
report_case = fut.result()
|
|
221
|
+
ordered[idx] = report_case
|
|
222
|
+
flag = "OK" if report_case.success else "FAIL"
|
|
223
|
+
console.print(
|
|
224
|
+
f" [{flag}] {report_case.name} ({report_case.duration_s:.1f}s)"
|
|
225
|
+
)
|
|
226
|
+
result.cases = [c for c in ordered if c is not None]
|
|
227
|
+
|
|
228
|
+
result.duration_s = time.monotonic() - t0
|
|
229
|
+
|
|
230
|
+
suite_dir = cfg.report_dir_path() / f"suite-{name}-{stamp}"
|
|
231
|
+
suite_dir.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
result.suite_dir = str(suite_dir)
|
|
233
|
+
|
|
234
|
+
if cfg.run.reports:
|
|
235
|
+
result.reports = write_suite_reports(
|
|
236
|
+
result.cases,
|
|
237
|
+
suite_dir,
|
|
238
|
+
formats=cfg.run.reports,
|
|
239
|
+
suite_name=name,
|
|
240
|
+
started_at=started_at,
|
|
241
|
+
duration_s=result.duration_s,
|
|
242
|
+
)
|
|
243
|
+
if result.reports.get("html"):
|
|
244
|
+
console.print(f"[green]Suite HTML[/green] → {result.reports['html']}")
|
|
245
|
+
if result.reports.get("junit"):
|
|
246
|
+
console.print(f"[green]Suite JUnit[/green] → {result.reports['junit']}")
|
|
247
|
+
|
|
248
|
+
summary_path = suite_dir / "suite.json"
|
|
249
|
+
summary_path.write_text(
|
|
250
|
+
json.dumps(result.to_dict(), indent=2) + "\n", encoding="utf-8"
|
|
251
|
+
)
|
|
252
|
+
latest = cfg.report_dir_path() / "suite.latest.json"
|
|
253
|
+
latest.parent.mkdir(parents=True, exist_ok=True)
|
|
254
|
+
latest.write_text(summary_path.read_text(encoding="utf-8"), encoding="utf-8")
|
|
255
|
+
|
|
256
|
+
status = "OK" if result.success else "FAIL"
|
|
257
|
+
color = "green" if result.success else "red"
|
|
258
|
+
console.print(
|
|
259
|
+
f"\n[bold {color}]Suite {status}[/bold {color}] "
|
|
260
|
+
f"{result.passed}/{result.total} passed "
|
|
261
|
+
f"({result.duration_s:.1f}s) → {suite_dir}"
|
|
262
|
+
)
|
|
263
|
+
return result
|