@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
|
Binary file
|
|
@@ -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
|