@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.
Files changed (39) hide show
  1. package/README.md +9 -11
  2. package/bin/mobiflow.js +94 -61
  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,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