@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,682 @@
1
+ """Run reporting: JUnit XML, HTML summary, artifact indexing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import json
7
+ import re
8
+ import shutil
9
+ import xml.etree.ElementTree as ET
10
+ from dataclasses import dataclass, field
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from xml.dom import minidom
15
+
16
+ IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
17
+
18
+
19
+ @dataclass
20
+ class ReportCase:
21
+ name: str
22
+ success: bool
23
+ summary: str = ""
24
+ error: str = ""
25
+ task: str = ""
26
+ platform: str = ""
27
+ provider: str = "local"
28
+ device_id: str = ""
29
+ duration_s: float = 0.0
30
+ flow_path: str = ""
31
+ dashboard_url: str = ""
32
+ build_id: str = ""
33
+ stdout: str = ""
34
+ stderr: str = ""
35
+ logs: list[str] = field(default_factory=list)
36
+ synthesis_only: bool = False
37
+ screenshot_paths: list[str] = field(default_factory=list)
38
+ artifact_dir: str = ""
39
+ started_at: str = ""
40
+ video_url: str = ""
41
+ explore_usage: dict[str, Any] = field(default_factory=dict)
42
+ codegen_usage: dict[str, Any] = field(default_factory=dict)
43
+
44
+
45
+ def normalize_report_formats(value: Any) -> list[str]:
46
+ """Accept list/tuple/comma-string; return lowercase unique formats."""
47
+ if value is None:
48
+ return []
49
+ if isinstance(value, str):
50
+ items = [p.strip().lower() for p in value.replace(";", ",").split(",")]
51
+ elif isinstance(value, (list, tuple, set)):
52
+ items = [str(p).strip().lower() for p in value]
53
+ else:
54
+ items = [str(value).strip().lower()]
55
+ out: list[str] = []
56
+ for item in items:
57
+ if not item or item in {"none", "off", "false", "0"}:
58
+ continue
59
+ if item in {"junit", "xml"}:
60
+ fmt = "junit"
61
+ elif item in {"html", "htm"}:
62
+ fmt = "html"
63
+ else:
64
+ fmt = item
65
+ if fmt not in out:
66
+ out.append(fmt)
67
+ return out
68
+
69
+
70
+ def collect_screenshots(root: Path, *, limit: int = 40) -> list[Path]:
71
+ if not root.exists():
72
+ return []
73
+ found: list[Path] = []
74
+ for path in sorted(root.rglob("*")):
75
+ if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES:
76
+ found.append(path)
77
+ if len(found) >= limit:
78
+ break
79
+ return found
80
+
81
+
82
+ def index_artifacts(root: Path) -> dict[str, Any]:
83
+ """Build a lightweight inventory of a run artifact directory."""
84
+ if not root.exists():
85
+ return {"dir": str(root), "files": [], "screenshots": []}
86
+ files: list[str] = []
87
+ screenshots: list[str] = []
88
+ for path in sorted(root.rglob("*")):
89
+ if not path.is_file():
90
+ continue
91
+ rel = str(path.relative_to(root)).replace("\\", "/")
92
+ files.append(rel)
93
+ if path.suffix.lower() in IMAGE_SUFFIXES:
94
+ screenshots.append(rel)
95
+ return {
96
+ "dir": str(root),
97
+ "files": files,
98
+ "screenshots": screenshots,
99
+ "file_count": len(files),
100
+ "screenshot_count": len(screenshots),
101
+ }
102
+
103
+
104
+ def copy_tree_if_present(src: Path, dest: Path) -> Path | None:
105
+ if not src.exists():
106
+ return None
107
+ if dest.exists():
108
+ shutil.rmtree(dest)
109
+ shutil.copytree(src, dest)
110
+ return dest
111
+
112
+
113
+ def write_junit_xml(case: ReportCase, path: Path) -> Path:
114
+ """Write a single-testcase JUnit XML report."""
115
+ path.parent.mkdir(parents=True, exist_ok=True)
116
+ suite = ET.Element(
117
+ "testsuite",
118
+ {
119
+ "name": "MobiFlow",
120
+ "tests": "1",
121
+ "failures": "0" if case.success else "1",
122
+ "errors": "0",
123
+ "skipped": "1" if case.synthesis_only and case.success else "0",
124
+ "time": f"{max(0.0, case.duration_s):.3f}",
125
+ "timestamp": case.started_at
126
+ or datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
127
+ },
128
+ )
129
+ classname = f"{case.provider}.{case.platform or 'mobile'}".strip(".")
130
+ testcase = ET.SubElement(
131
+ suite,
132
+ "testcase",
133
+ {
134
+ "name": case.name,
135
+ "classname": classname or "mobiflow",
136
+ "time": f"{max(0.0, case.duration_s):.3f}",
137
+ },
138
+ )
139
+ props = ET.SubElement(testcase, "properties")
140
+ for key, val in (
141
+ ("platform", case.platform),
142
+ ("provider", case.provider),
143
+ ("device_id", case.device_id),
144
+ ("dashboard_url", case.dashboard_url),
145
+ ("build_id", case.build_id),
146
+ ("flow_path", case.flow_path),
147
+ ("artifact_dir", case.artifact_dir),
148
+ ):
149
+ if val:
150
+ ET.SubElement(props, "property", {"name": key, "value": str(val)})
151
+
152
+ system_out = "\n".join(case.logs)
153
+ if case.stdout:
154
+ system_out = (system_out + "\n" + case.stdout).strip()
155
+ if system_out:
156
+ ET.SubElement(testcase, "system-out").text = system_out[-20000:]
157
+ if case.stderr:
158
+ ET.SubElement(testcase, "system-err").text = case.stderr[-20000:]
159
+
160
+ if case.synthesis_only and case.success:
161
+ ET.SubElement(testcase, "skipped", {"message": case.summary or "synthesis only"})
162
+ elif not case.success:
163
+ msg = case.summary or case.error or "flow failed"
164
+ fail = ET.SubElement(testcase, "failure", {"message": msg[:500]})
165
+ fail.text = (case.stderr or case.stdout or case.error or msg)[-20000:]
166
+
167
+ rough = ET.tostring(suite, encoding="utf-8")
168
+ pretty = minidom.parseString(rough).toprettyxml(indent=" ", encoding="utf-8")
169
+ # minidom adds XML declaration; write bytes
170
+ path.write_bytes(pretty)
171
+ return path
172
+
173
+
174
+ def write_html_report(case: ReportCase, path: Path) -> Path:
175
+ """Write a self-contained HTML run summary."""
176
+ path.parent.mkdir(parents=True, exist_ok=True)
177
+ status = "PASSED" if case.success else "FAILED"
178
+ if case.synthesis_only:
179
+ status = "GENERATED" if case.success else "FAILED"
180
+ color = "#0a7a32" if case.success else "#b42318"
181
+ if case.synthesis_only and case.success:
182
+ color = "#175cd3"
183
+
184
+ shots_html = ""
185
+ for rel in case.screenshot_paths[:24]:
186
+ safe = html.escape(rel)
187
+ shots_html += (
188
+ f'<figure class="shot"><img src="{safe}" alt="{safe}"/>'
189
+ f"<figcaption>{safe}</figcaption></figure>\n"
190
+ )
191
+ if not shots_html:
192
+ shots_html = "<p class='muted'>No screenshots captured for this run.</p>"
193
+
194
+ def block(title: str, body: str) -> str:
195
+ if not (body or "").strip():
196
+ return ""
197
+ return (
198
+ f"<h2>{html.escape(title)}</h2>"
199
+ f"<pre>{html.escape(body[-30000:])}</pre>"
200
+ )
201
+
202
+ meta_rows = "".join(
203
+ f"<tr><th>{html.escape(k)}</th><td>{html.escape(str(v))}</td></tr>"
204
+ for k, v in (
205
+ ("Case", case.name),
206
+ ("Status", status),
207
+ ("Summary", case.summary or "-"),
208
+ ("Provider", case.provider or "-"),
209
+ ("Platform", case.platform or "-"),
210
+ ("Device", case.device_id or "-"),
211
+ ("Duration", f"{case.duration_s:.1f}s"),
212
+ ("Started", case.started_at or "-"),
213
+ ("Flow", case.flow_path or "-"),
214
+ ("Build ID", case.build_id or "-"),
215
+ ("Dashboard", case.dashboard_url or "-"),
216
+ ("Artifacts", case.artifact_dir or "-"),
217
+ )
218
+ if v not in (None, "")
219
+ )
220
+
221
+ dash_link = ""
222
+ if case.dashboard_url:
223
+ url = html.escape(case.dashboard_url)
224
+ dash_link = f'<p><a href="{url}">Open cloud dashboard</a></p>'
225
+ if case.video_url:
226
+ vurl = html.escape(case.video_url)
227
+ dash_link += f'<p><a href="{vurl}">Open session video</a></p>'
228
+
229
+ progress_block = block("Progress log", "\n".join(case.logs))
230
+ stdout_block = block("Stdout", case.stdout)
231
+ stderr_block = block("Stderr", case.stderr)
232
+ error_block = block("Error", case.error)
233
+ safe_name = html.escape(case.name)
234
+ safe_task = html.escape(case.task or "")
235
+
236
+ doc = f"""<!DOCTYPE html>
237
+ <html lang="en">
238
+ <head>
239
+ <meta charset="utf-8"/>
240
+ <title>MobiFlow — {safe_name} — {status}</title>
241
+ <style>
242
+ :root {{
243
+ --bg: #f6f4ef;
244
+ --ink: #1c1917;
245
+ --card: #fffdf8;
246
+ --line: #e7e0d5;
247
+ --muted: #78716c;
248
+ --accent: {color};
249
+ }}
250
+ body {{
251
+ margin: 0; font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
252
+ background:
253
+ radial-gradient(1200px 500px at 10% -10%, #e8f0e4 0%, transparent 55%),
254
+ radial-gradient(900px 400px at 100% 0%, #f0e6d8 0%, transparent 50%),
255
+ var(--bg);
256
+ color: var(--ink); line-height: 1.45;
257
+ }}
258
+ main {{ max-width: 960px; margin: 2rem auto; padding: 0 1.25rem 3rem; }}
259
+ header {{
260
+ background: var(--card); border: 1px solid var(--line);
261
+ border-radius: 16px; padding: 1.25rem 1.5rem; margin-bottom: 1.25rem;
262
+ }}
263
+ h1 {{ margin: 0 0 .35rem; font-size: 1.6rem; letter-spacing: -0.02em; }}
264
+ .brand {{ font-size: .85rem; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); }}
265
+ .badge {{
266
+ display: inline-block; margin-top: .5rem; padding: .25rem .7rem;
267
+ border-radius: 999px; background: var(--accent); color: white;
268
+ font-weight: 650; font-size: .85rem;
269
+ }}
270
+ section {{
271
+ background: var(--card); border: 1px solid var(--line);
272
+ border-radius: 16px; padding: 1.1rem 1.35rem; margin-bottom: 1rem;
273
+ }}
274
+ h2 {{ margin: 0 0 .75rem; font-size: 1.05rem; }}
275
+ table {{ width: 100%; border-collapse: collapse; }}
276
+ th, td {{ text-align: left; vertical-align: top; padding: .4rem 0; border-bottom: 1px solid var(--line); }}
277
+ th {{ width: 8rem; color: var(--muted); font-weight: 600; }}
278
+ pre {{
279
+ white-space: pre-wrap; word-break: break-word; background: #1c1917; color: #f5f5f4;
280
+ padding: .9rem 1rem; border-radius: 12px; overflow: auto; font-size: .82rem;
281
+ }}
282
+ .shots {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: .75rem; }}
283
+ .shot {{ margin: 0; }}
284
+ .shot img {{ width: 100%; border-radius: 10px; border: 1px solid var(--line); background: #fff; }}
285
+ figcaption {{ font-size: .72rem; color: var(--muted); margin-top: .3rem; word-break: break-all; }}
286
+ .muted {{ color: var(--muted); }}
287
+ a {{ color: #0f4c81; }}
288
+ </style>
289
+ </head>
290
+ <body>
291
+ <main>
292
+ <header>
293
+ <div class="brand">MobiFlow report</div>
294
+ <h1>{safe_name}</h1>
295
+ <div class="badge">{status}</div>
296
+ {dash_link}
297
+ </header>
298
+ <section>
299
+ <h2>Run details</h2>
300
+ <table>{meta_rows}</table>
301
+ <p class="muted">{safe_task}</p>
302
+ </section>
303
+ <section>
304
+ <h2>Screenshots</h2>
305
+ <div class="shots">{shots_html}</div>
306
+ </section>
307
+ <section>
308
+ {progress_block}
309
+ {stdout_block}
310
+ {stderr_block}
311
+ {error_block}
312
+ </section>
313
+ </main>
314
+ </body>
315
+ </html>
316
+ """
317
+ path.write_text(doc, encoding="utf-8")
318
+ return path
319
+
320
+
321
+ def merge_maestro_junit(maestro_junit: Path, case: ReportCase, dest: Path) -> Path:
322
+ """Prefer Maestro's JUnit when present; enrich classname/name if needed."""
323
+ if not maestro_junit.is_file():
324
+ return write_junit_xml(case, dest)
325
+ try:
326
+ tree = ET.parse(maestro_junit)
327
+ root = tree.getroot()
328
+ # Ensure at least one testcase has our case name property
329
+ for tc in root.iter("testcase"):
330
+ if not tc.get("name"):
331
+ tc.set("name", case.name)
332
+ dest.parent.mkdir(parents=True, exist_ok=True)
333
+ tree.write(dest, encoding="utf-8", xml_declaration=True)
334
+ return dest
335
+ except ET.ParseError:
336
+ return write_junit_xml(case, dest)
337
+
338
+
339
+ def write_run_reports(
340
+ case: ReportCase,
341
+ report_dir: Path,
342
+ *,
343
+ formats: list[str],
344
+ maestro_junit: Path | None = None,
345
+ ) -> dict[str, str]:
346
+ """Write configured reports; return map format → path."""
347
+ formats = normalize_report_formats(formats)
348
+ report_dir.mkdir(parents=True, exist_ok=True)
349
+ written: dict[str, str] = {}
350
+ if "junit" in formats:
351
+ junit_path = report_dir / "junit.xml"
352
+ if maestro_junit and maestro_junit.is_file():
353
+ merge_maestro_junit(maestro_junit, case, junit_path)
354
+ else:
355
+ write_junit_xml(case, junit_path)
356
+ written["junit"] = str(junit_path)
357
+ if "html" in formats:
358
+ # Legacy lightweight HTML kept for quick glance / CI mirrors
359
+ simple_path = report_dir / "report-simple.html"
360
+ write_html_report(case, simple_path)
361
+ written["html_simple"] = str(simple_path)
362
+ try:
363
+ from mobiflow.report import write_rich_reports
364
+
365
+ rich = write_rich_reports(
366
+ [case],
367
+ report_dir,
368
+ title=f"MobiFlow — {case.name}",
369
+ )
370
+ written["html"] = rich.get("html") or rich.get("report") or ""
371
+ written["pack"] = rich.get("json") or ""
372
+ except FileNotFoundError as exc:
373
+ # Fall back to simple template if SPA bundle missing
374
+ html_path = report_dir / "report.html"
375
+ write_html_report(case, html_path)
376
+ written["html"] = str(html_path)
377
+ written["html_error"] = str(exc)
378
+ # Always write a machine-readable index alongside reports
379
+ index = {
380
+ "case": case.name,
381
+ "success": case.success,
382
+ "summary": case.summary,
383
+ "provider": case.provider,
384
+ "platform": case.platform,
385
+ "device_id": case.device_id,
386
+ "dashboard_url": case.dashboard_url,
387
+ "build_id": case.build_id,
388
+ "artifact_dir": case.artifact_dir,
389
+ "screenshots": case.screenshot_paths,
390
+ "reports": written,
391
+ "generated_at": datetime.now(UTC).isoformat(),
392
+ }
393
+ index_path = report_dir / "index.json"
394
+ index_path.write_text(json.dumps(index, indent=2) + "\n", encoding="utf-8")
395
+ written["index"] = str(index_path)
396
+ return written
397
+
398
+
399
+ def write_suite_junit(
400
+ cases: list[ReportCase],
401
+ path: Path,
402
+ *,
403
+ suite_name: str = "MobiFlow",
404
+ started_at: str = "",
405
+ duration_s: float = 0.0,
406
+ ) -> Path:
407
+ """Write a multi-testcase JUnit XML suite report."""
408
+ path.parent.mkdir(parents=True, exist_ok=True)
409
+ failures = sum(1 for c in cases if not c.success)
410
+ skipped = sum(1 for c in cases if c.synthesis_only and c.success)
411
+ total_time = duration_s if duration_s > 0 else sum(max(0.0, c.duration_s) for c in cases)
412
+ suite = ET.Element(
413
+ "testsuite",
414
+ {
415
+ "name": suite_name,
416
+ "tests": str(len(cases)),
417
+ "failures": str(failures),
418
+ "errors": "0",
419
+ "skipped": str(skipped),
420
+ "time": f"{max(0.0, total_time):.3f}",
421
+ "timestamp": started_at
422
+ or datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
423
+ },
424
+ )
425
+ for case in cases:
426
+ classname = f"{case.provider}.{case.platform or 'mobile'}".strip(".")
427
+ testcase = ET.SubElement(
428
+ suite,
429
+ "testcase",
430
+ {
431
+ "name": case.name,
432
+ "classname": classname or "mobiflow",
433
+ "time": f"{max(0.0, case.duration_s):.3f}",
434
+ },
435
+ )
436
+ props = ET.SubElement(testcase, "properties")
437
+ for key, val in (
438
+ ("platform", case.platform),
439
+ ("provider", case.provider),
440
+ ("device_id", case.device_id),
441
+ ("dashboard_url", case.dashboard_url),
442
+ ("build_id", case.build_id),
443
+ ("flow_path", case.flow_path),
444
+ ("artifact_dir", case.artifact_dir),
445
+ ):
446
+ if val:
447
+ ET.SubElement(props, "property", {"name": key, "value": str(val)})
448
+ system_out = "\n".join(case.logs)
449
+ if case.stdout:
450
+ system_out = (system_out + "\n" + case.stdout).strip()
451
+ if system_out:
452
+ ET.SubElement(testcase, "system-out").text = system_out[-20000:]
453
+ if case.stderr:
454
+ ET.SubElement(testcase, "system-err").text = case.stderr[-20000:]
455
+ if case.synthesis_only and case.success:
456
+ ET.SubElement(
457
+ testcase, "skipped", {"message": case.summary or "synthesis only"}
458
+ )
459
+ elif not case.success:
460
+ msg = case.summary or case.error or "flow failed"
461
+ fail = ET.SubElement(testcase, "failure", {"message": msg[:500]})
462
+ fail.text = (case.stderr or case.stdout or case.error or msg)[-20000:]
463
+
464
+ rough = ET.tostring(suite, encoding="utf-8")
465
+ pretty = minidom.parseString(rough).toprettyxml(indent=" ", encoding="utf-8")
466
+ path.write_bytes(pretty)
467
+ return path
468
+
469
+
470
+ def write_suite_html(
471
+ cases: list[ReportCase],
472
+ path: Path,
473
+ *,
474
+ suite_name: str = "MobiFlow",
475
+ started_at: str = "",
476
+ duration_s: float = 0.0,
477
+ ) -> Path:
478
+ """Write an HTML index for a multi-case suite."""
479
+ path.parent.mkdir(parents=True, exist_ok=True)
480
+ passed = sum(1 for c in cases if c.success)
481
+ failed = len(cases) - passed
482
+ ok = failed == 0 and len(cases) > 0
483
+ status = "PASSED" if ok else ("EMPTY" if not cases else "FAILED")
484
+ color = "#0a7a32" if ok else ("#78716c" if not cases else "#b42318")
485
+ total_time = duration_s if duration_s > 0 else sum(c.duration_s for c in cases)
486
+
487
+ rows = []
488
+ for case in cases:
489
+ st = "PASS" if case.success else "FAIL"
490
+ if case.synthesis_only and case.success:
491
+ st = "GEN"
492
+ st_color = "#0a7a32" if case.success else "#b42318"
493
+ if case.synthesis_only and case.success:
494
+ st_color = "#175cd3"
495
+ art = html.escape(case.artifact_dir or "-")
496
+ dash = ""
497
+ if case.dashboard_url:
498
+ u = html.escape(case.dashboard_url)
499
+ dash = f'<a href="{u}">dashboard</a>'
500
+ rows.append(
501
+ "<tr>"
502
+ f"<td><strong>{html.escape(case.name)}</strong></td>"
503
+ f'<td><span style="color:{st_color};font-weight:650">{st}</span></td>'
504
+ f"<td>{html.escape(case.summary or case.error or '-')}</td>"
505
+ f"<td>{case.duration_s:.1f}s</td>"
506
+ f"<td>{html.escape(case.provider or '-')}</td>"
507
+ f"<td>{html.escape(case.platform or '-')}</td>"
508
+ f"<td class='muted'>{art}</td>"
509
+ f"<td>{dash}</td>"
510
+ "</tr>"
511
+ )
512
+ table_body = "\n".join(rows) or (
513
+ "<tr><td colspan='8' class='muted'>No cases in suite.</td></tr>"
514
+ )
515
+ safe_name = html.escape(suite_name)
516
+
517
+ doc = f"""<!DOCTYPE html>
518
+ <html lang="en">
519
+ <head>
520
+ <meta charset="utf-8"/>
521
+ <title>MobiFlow suite — {safe_name} — {status}</title>
522
+ <style>
523
+ :root {{
524
+ --bg: #f6f4ef; --ink: #1c1917; --card: #fffdf8;
525
+ --line: #e7e0d5; --muted: #78716c; --accent: {color};
526
+ }}
527
+ body {{
528
+ margin: 0; font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
529
+ background:
530
+ radial-gradient(1200px 500px at 10% -10%, #e8f0e4 0%, transparent 55%),
531
+ radial-gradient(900px 400px at 100% 0%, #f0e6d8 0%, transparent 50%),
532
+ var(--bg);
533
+ color: var(--ink); line-height: 1.45;
534
+ }}
535
+ main {{ max-width: 1100px; margin: 2rem auto; padding: 0 1.25rem 3rem; }}
536
+ header {{
537
+ background: var(--card); border: 1px solid var(--line);
538
+ border-radius: 16px; padding: 1.25rem 1.5rem; margin-bottom: 1.25rem;
539
+ }}
540
+ h1 {{ margin: 0 0 .35rem; font-size: 1.6rem; letter-spacing: -0.02em; }}
541
+ .brand {{ font-size: .85rem; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); }}
542
+ .badge {{
543
+ display: inline-block; margin-top: .5rem; padding: .25rem .7rem;
544
+ border-radius: 999px; background: var(--accent); color: white;
545
+ font-weight: 650; font-size: .85rem;
546
+ }}
547
+ .stats {{ margin-top: .75rem; color: var(--muted); font-size: .95rem; }}
548
+ section {{
549
+ background: var(--card); border: 1px solid var(--line);
550
+ border-radius: 16px; padding: 1.1rem 1.35rem; margin-bottom: 1rem;
551
+ overflow-x: auto;
552
+ }}
553
+ table {{ width: 100%; border-collapse: collapse; font-size: .9rem; }}
554
+ th, td {{ text-align: left; vertical-align: top; padding: .55rem .4rem; border-bottom: 1px solid var(--line); }}
555
+ th {{ color: var(--muted); font-weight: 600; }}
556
+ .muted {{ color: var(--muted); font-size: .78rem; word-break: break-all; }}
557
+ a {{ color: #0f4c81; }}
558
+ </style>
559
+ </head>
560
+ <body>
561
+ <main>
562
+ <header>
563
+ <div class="brand">MobiFlow suite report</div>
564
+ <h1>{safe_name}</h1>
565
+ <div class="badge">{status}</div>
566
+ <div class="stats">
567
+ {passed} passed · {failed} failed · {len(cases)} total ·
568
+ {total_time:.1f}s · started {html.escape(started_at or "-")}
569
+ </div>
570
+ </header>
571
+ <section>
572
+ <table>
573
+ <thead>
574
+ <tr>
575
+ <th>Case</th><th>Status</th><th>Summary</th><th>Time</th>
576
+ <th>Provider</th><th>Platform</th><th>Artifacts</th><th>Link</th>
577
+ </tr>
578
+ </thead>
579
+ <tbody>
580
+ {table_body}
581
+ </tbody>
582
+ </table>
583
+ </section>
584
+ </main>
585
+ </body>
586
+ </html>
587
+ """
588
+ path.write_text(doc, encoding="utf-8")
589
+ return path
590
+
591
+
592
+ def write_suite_reports(
593
+ cases: list[ReportCase],
594
+ report_dir: Path,
595
+ *,
596
+ formats: list[str],
597
+ suite_name: str = "MobiFlow",
598
+ started_at: str = "",
599
+ duration_s: float = 0.0,
600
+ ) -> dict[str, str]:
601
+ """Write suite-level JUnit/HTML/index; return map format → path."""
602
+ formats = normalize_report_formats(formats)
603
+ report_dir.mkdir(parents=True, exist_ok=True)
604
+ written: dict[str, str] = {}
605
+ if "junit" in formats:
606
+ junit_path = report_dir / "junit.xml"
607
+ write_suite_junit(
608
+ cases,
609
+ junit_path,
610
+ suite_name=suite_name,
611
+ started_at=started_at,
612
+ duration_s=duration_s,
613
+ )
614
+ written["junit"] = str(junit_path)
615
+ if "html" in formats:
616
+ simple_path = report_dir / "report-simple.html"
617
+ write_suite_html(
618
+ cases,
619
+ simple_path,
620
+ suite_name=suite_name,
621
+ started_at=started_at,
622
+ duration_s=duration_s,
623
+ )
624
+ written["html_simple"] = str(simple_path)
625
+ try:
626
+ from mobiflow.report import write_rich_reports
627
+
628
+ rich = write_rich_reports(
629
+ cases,
630
+ report_dir,
631
+ title=f"MobiFlow Suite — {suite_name}",
632
+ )
633
+ written["html"] = rich.get("html") or rich.get("report") or ""
634
+ written["pack"] = rich.get("json") or ""
635
+ except FileNotFoundError as exc:
636
+ html_path = report_dir / "report.html"
637
+ write_suite_html(
638
+ cases,
639
+ html_path,
640
+ suite_name=suite_name,
641
+ started_at=started_at,
642
+ duration_s=duration_s,
643
+ )
644
+ written["html"] = str(html_path)
645
+ written["html_error"] = str(exc)
646
+ index = {
647
+ "suite": suite_name,
648
+ "success": bool(cases) and all(c.success for c in cases),
649
+ "total": len(cases),
650
+ "passed": sum(1 for c in cases if c.success),
651
+ "failed": sum(1 for c in cases if not c.success),
652
+ "duration_s": duration_s,
653
+ "started_at": started_at,
654
+ "cases": [
655
+ {
656
+ "name": c.name,
657
+ "success": c.success,
658
+ "summary": c.summary,
659
+ "duration_s": c.duration_s,
660
+ "artifact_dir": c.artifact_dir,
661
+ }
662
+ for c in cases
663
+ ],
664
+ "reports": written,
665
+ "generated_at": datetime.now(UTC).isoformat(),
666
+ }
667
+ index_path = report_dir / "index.json"
668
+ index_path.write_text(json.dumps(index, indent=2) + "\n", encoding="utf-8")
669
+ written["index"] = str(index_path)
670
+ return written
671
+
672
+
673
+ _DURATION_RE = re.compile(r"(?i)(?:duration|elapsed)[^\d]*(\d+(?:\.\d+)?)\s*(s|ms)?")
674
+
675
+
676
+ def estimate_duration_s(stdout: str, stderr: str, fallback: float = 0.0) -> float:
677
+ text = f"{stdout}\n{stderr}"
678
+ for match in _DURATION_RE.finditer(text):
679
+ val = float(match.group(1))
680
+ unit = (match.group(2) or "s").lower()
681
+ return val / 1000.0 if unit == "ms" else val
682
+ return fallback