@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,617 @@
|
|
|
1
|
+
"""Rich execution report packs (Vite/React SPA + JSON inject)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import platform
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from importlib import resources
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from mobiflow.reporting import ReportCase
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def utc_now_iso() -> str:
|
|
20
|
+
return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class TokenUsage:
|
|
25
|
+
prompt_tokens: int = 0
|
|
26
|
+
completion_tokens: int = 0
|
|
27
|
+
total_tokens: int = 0
|
|
28
|
+
cost: float = 0.0
|
|
29
|
+
|
|
30
|
+
def merged(self, other: TokenUsage) -> TokenUsage:
|
|
31
|
+
return TokenUsage(
|
|
32
|
+
prompt_tokens=self.prompt_tokens + other.prompt_tokens,
|
|
33
|
+
completion_tokens=self.completion_tokens + other.completion_tokens,
|
|
34
|
+
total_tokens=self.total_tokens + other.total_tokens,
|
|
35
|
+
cost=self.cost + other.cost,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class StepRecord:
|
|
41
|
+
index: int
|
|
42
|
+
action: str
|
|
43
|
+
status: str # pass | fail | skipped | info
|
|
44
|
+
raw: str = ""
|
|
45
|
+
detail: str = ""
|
|
46
|
+
duration_ms: int = 0
|
|
47
|
+
screenshot: str | None = None
|
|
48
|
+
url: str | None = None
|
|
49
|
+
value: str | None = None
|
|
50
|
+
locator: str | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class PhaseStatus:
|
|
55
|
+
name: str
|
|
56
|
+
status: str # success | failed | skipped | not_run
|
|
57
|
+
detail: str = ""
|
|
58
|
+
duration_ms: int = 0
|
|
59
|
+
usage: TokenUsage = field(default_factory=TokenUsage)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class CaseRecord:
|
|
64
|
+
id: str
|
|
65
|
+
name: str
|
|
66
|
+
status: str # passed | failed | skipped | error
|
|
67
|
+
mode: str # ai | scripted
|
|
68
|
+
url: str = ""
|
|
69
|
+
tags: list[str] = field(default_factory=list)
|
|
70
|
+
title: str | None = None
|
|
71
|
+
started_at: str = field(default_factory=utc_now_iso)
|
|
72
|
+
finished_at: str = field(default_factory=utc_now_iso)
|
|
73
|
+
duration_ms: int = 0
|
|
74
|
+
phases: list[PhaseStatus] = field(default_factory=list)
|
|
75
|
+
steps: list[StepRecord] = field(default_factory=list)
|
|
76
|
+
explore_usage: TokenUsage = field(default_factory=TokenUsage)
|
|
77
|
+
codegen_usage: TokenUsage = field(default_factory=TokenUsage)
|
|
78
|
+
files_generated: list[str] = field(default_factory=list)
|
|
79
|
+
heal_attempts: int = 0
|
|
80
|
+
failure_output: str = ""
|
|
81
|
+
errors: list[str] = field(default_factory=list)
|
|
82
|
+
artifacts: dict[str, Any] = field(default_factory=dict)
|
|
83
|
+
source_case: str | None = None
|
|
84
|
+
prompt: str | None = None
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def total_usage(self) -> TokenUsage:
|
|
88
|
+
return self.explore_usage.merged(self.codegen_usage)
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> dict[str, Any]:
|
|
91
|
+
data = asdict(self)
|
|
92
|
+
data["total_usage"] = asdict(self.total_usage)
|
|
93
|
+
return data
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class EnvInfo:
|
|
98
|
+
generated_at: str = field(default_factory=utc_now_iso)
|
|
99
|
+
python: str = ""
|
|
100
|
+
platform: str = "" # host OS
|
|
101
|
+
mobiflow_version: str = ""
|
|
102
|
+
maestro: str = ""
|
|
103
|
+
llm_provider: str = ""
|
|
104
|
+
stack_tool: str = "maestro"
|
|
105
|
+
stack_language: str = ""
|
|
106
|
+
stack_runner: str = "maestro"
|
|
107
|
+
repo_path: str = ""
|
|
108
|
+
# Mobile target
|
|
109
|
+
device: str = ""
|
|
110
|
+
app_id: str = ""
|
|
111
|
+
mobile_platform: str = ""
|
|
112
|
+
device_provider: str = ""
|
|
113
|
+
# Legacy aliases (older SPA / packs)
|
|
114
|
+
browser: str = "" # → device
|
|
115
|
+
url: str = "" # → app_id
|
|
116
|
+
playwright: str = ""
|
|
117
|
+
explore_model: str = ""
|
|
118
|
+
codegen_model: str = ""
|
|
119
|
+
headless: bool = False
|
|
120
|
+
azure_endpoint: str | None = None
|
|
121
|
+
|
|
122
|
+
def to_dict(self) -> dict[str, Any]:
|
|
123
|
+
data = asdict(self)
|
|
124
|
+
# Prefer explicit mobile fields; keep aliases populated for older UIs
|
|
125
|
+
device = self.device or self.browser
|
|
126
|
+
app_id = self.app_id or self.url
|
|
127
|
+
data["device"] = device
|
|
128
|
+
data["app_id"] = app_id
|
|
129
|
+
data["browser"] = device
|
|
130
|
+
data["url"] = app_id
|
|
131
|
+
# Drop browser-automation noise from packs unless set
|
|
132
|
+
for key in ("playwright", "explore_model", "codegen_model", "azure_endpoint"):
|
|
133
|
+
if not data.get(key):
|
|
134
|
+
data.pop(key, None)
|
|
135
|
+
if not data.get("headless"):
|
|
136
|
+
data.pop("headless", None)
|
|
137
|
+
return data
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class PackSummary:
|
|
142
|
+
total: int = 0
|
|
143
|
+
passed: int = 0
|
|
144
|
+
failed: int = 0
|
|
145
|
+
skipped: int = 0
|
|
146
|
+
error: int = 0
|
|
147
|
+
duration_ms: int = 0
|
|
148
|
+
prompt_tokens: int = 0
|
|
149
|
+
completion_tokens: int = 0
|
|
150
|
+
total_tokens: int = 0
|
|
151
|
+
cost: float = 0.0
|
|
152
|
+
pass_rate: float = 0.0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class TrendPoint:
|
|
157
|
+
label: str
|
|
158
|
+
pass_rate: float
|
|
159
|
+
duration_ms: int
|
|
160
|
+
cost: float
|
|
161
|
+
total: int
|
|
162
|
+
passed: int
|
|
163
|
+
failed: int
|
|
164
|
+
at: str
|
|
165
|
+
total_tokens: int = 0
|
|
166
|
+
prompt_tokens: int = 0
|
|
167
|
+
completion_tokens: int = 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass
|
|
171
|
+
class Insight:
|
|
172
|
+
severity: str
|
|
173
|
+
title: str
|
|
174
|
+
body: str
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class ReportPack:
|
|
179
|
+
id: str
|
|
180
|
+
title: str
|
|
181
|
+
generated_at: str
|
|
182
|
+
env: EnvInfo
|
|
183
|
+
summary: PackSummary
|
|
184
|
+
cases: list[CaseRecord] = field(default_factory=list)
|
|
185
|
+
trends: list[TrendPoint] = field(default_factory=list)
|
|
186
|
+
insights: list[Insight] = field(default_factory=list)
|
|
187
|
+
|
|
188
|
+
def to_dict(self) -> dict[str, Any]:
|
|
189
|
+
return {
|
|
190
|
+
"id": self.id,
|
|
191
|
+
"title": self.title,
|
|
192
|
+
"generated_at": self.generated_at,
|
|
193
|
+
"env": self.env.to_dict(),
|
|
194
|
+
"summary": asdict(self.summary),
|
|
195
|
+
"cases": [c.to_dict() for c in self.cases],
|
|
196
|
+
"trends": [asdict(t) for t in self.trends],
|
|
197
|
+
"insights": [asdict(i) for i in self.insights],
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
_PLACEHOLDER_RE = re.compile(
|
|
202
|
+
r"<script>\s*window\.__MOBIFLOW_REPORT__\s*=\s*window\.__MOBIFLOW_REPORT__\s*\|\|\s*null\s*;\s*</script>",
|
|
203
|
+
re.MULTILINE,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _load_template() -> str:
|
|
208
|
+
try:
|
|
209
|
+
root = resources.files("mobiflow.report")
|
|
210
|
+
static = root.joinpath("static", "index.html")
|
|
211
|
+
if static.is_file():
|
|
212
|
+
return static.read_text(encoding="utf-8")
|
|
213
|
+
except Exception: # noqa: BLE001
|
|
214
|
+
pass
|
|
215
|
+
fallback = Path(__file__).resolve().parent / "static" / "index.html"
|
|
216
|
+
if fallback.is_file():
|
|
217
|
+
return fallback.read_text(encoding="utf-8")
|
|
218
|
+
raise FileNotFoundError(
|
|
219
|
+
"Report UI bundle missing. Build it with: "
|
|
220
|
+
"cd report-ui && npm install && npm run build"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def render_html(pack: ReportPack) -> str:
|
|
225
|
+
template = _load_template()
|
|
226
|
+
payload = json.dumps(pack.to_dict(), ensure_ascii=False, default=str)
|
|
227
|
+
safe = payload.replace("<", "\\u003c").replace(">", "\\u003e")
|
|
228
|
+
injection = f"<script>window.__MOBIFLOW_REPORT__ = {safe};</script>"
|
|
229
|
+
html = _PLACEHOLDER_RE.sub("", template)
|
|
230
|
+
if re.search(r"<head[^>]*>", html, flags=re.IGNORECASE):
|
|
231
|
+
html = re.sub(
|
|
232
|
+
r"(<head[^>]*>)",
|
|
233
|
+
lambda m: f"{m.group(1)}\n {injection}",
|
|
234
|
+
html,
|
|
235
|
+
count=1,
|
|
236
|
+
flags=re.IGNORECASE,
|
|
237
|
+
)
|
|
238
|
+
else:
|
|
239
|
+
html = injection + html
|
|
240
|
+
title = pack.title.replace("<", "<")
|
|
241
|
+
html = re.sub(
|
|
242
|
+
r"<title>.*?</title>",
|
|
243
|
+
f"<title>{title}</title>",
|
|
244
|
+
html,
|
|
245
|
+
count=1,
|
|
246
|
+
flags=re.IGNORECASE | re.DOTALL,
|
|
247
|
+
)
|
|
248
|
+
return html
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def write_pack_html(pack: ReportPack, out_path: Path) -> Path:
|
|
252
|
+
out_path = Path(out_path)
|
|
253
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
254
|
+
out_path.write_text(render_html(pack), encoding="utf-8")
|
|
255
|
+
return out_path
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def write_pack_json(pack: ReportPack, out_path: Path) -> Path:
|
|
259
|
+
out_path = Path(out_path)
|
|
260
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
out_path.write_text(
|
|
262
|
+
json.dumps(pack.to_dict(), indent=2, ensure_ascii=False) + "\n",
|
|
263
|
+
encoding="utf-8",
|
|
264
|
+
)
|
|
265
|
+
return out_path
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _abs_shots(case: ReportCase) -> list[str]:
|
|
269
|
+
shots: list[str] = []
|
|
270
|
+
base = Path(case.artifact_dir) if case.artifact_dir else None
|
|
271
|
+
for rel in case.screenshot_paths or []:
|
|
272
|
+
p = Path(rel)
|
|
273
|
+
if not p.is_absolute() and base is not None:
|
|
274
|
+
# report may store ../screenshots/x relative to report dir
|
|
275
|
+
cand = (base / "screenshots" / p.name).resolve()
|
|
276
|
+
if cand.is_file():
|
|
277
|
+
shots.append(str(cand))
|
|
278
|
+
continue
|
|
279
|
+
cand2 = (base / p).resolve()
|
|
280
|
+
if cand2.is_file():
|
|
281
|
+
shots.append(str(cand2))
|
|
282
|
+
continue
|
|
283
|
+
if p.is_file():
|
|
284
|
+
shots.append(str(p.resolve()))
|
|
285
|
+
else:
|
|
286
|
+
shots.append(str(p))
|
|
287
|
+
return shots
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _token_usage_from(raw: Any) -> TokenUsage:
|
|
291
|
+
if isinstance(raw, TokenUsage):
|
|
292
|
+
return raw
|
|
293
|
+
if not isinstance(raw, dict):
|
|
294
|
+
return TokenUsage()
|
|
295
|
+
return TokenUsage(
|
|
296
|
+
prompt_tokens=int(raw.get("prompt_tokens") or 0),
|
|
297
|
+
completion_tokens=int(raw.get("completion_tokens") or 0),
|
|
298
|
+
total_tokens=int(raw.get("total_tokens") or 0),
|
|
299
|
+
cost=float(raw.get("cost") or 0.0),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def case_record_from_report_case(
|
|
304
|
+
case: ReportCase,
|
|
305
|
+
*,
|
|
306
|
+
app_id: str = "",
|
|
307
|
+
mode: str = "ai",
|
|
308
|
+
tags: list[str] | None = None,
|
|
309
|
+
heal_attempts: int = 0,
|
|
310
|
+
source_case: str | None = None,
|
|
311
|
+
) -> CaseRecord:
|
|
312
|
+
status = "passed" if case.success else "failed"
|
|
313
|
+
if case.synthesis_only and case.success:
|
|
314
|
+
status = "passed"
|
|
315
|
+
duration_ms = int(max(0.0, float(case.duration_s or 0.0)) * 1000)
|
|
316
|
+
started = case.started_at or utc_now_iso()
|
|
317
|
+
phases: list[PhaseStatus] = []
|
|
318
|
+
if case.logs:
|
|
319
|
+
phases.append(
|
|
320
|
+
PhaseStatus(
|
|
321
|
+
name="run",
|
|
322
|
+
status="success" if case.success else "failed",
|
|
323
|
+
detail=case.summary or ("passed" if case.success else "failed"),
|
|
324
|
+
duration_ms=duration_ms,
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
steps: list[StepRecord] = []
|
|
328
|
+
for i, line in enumerate(case.logs or [], start=1):
|
|
329
|
+
steps.append(
|
|
330
|
+
StepRecord(
|
|
331
|
+
index=i,
|
|
332
|
+
action="log",
|
|
333
|
+
status="pass" if case.success else "info",
|
|
334
|
+
raw=line,
|
|
335
|
+
detail=line,
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
if not phases:
|
|
339
|
+
phases.append(
|
|
340
|
+
PhaseStatus(
|
|
341
|
+
name="run",
|
|
342
|
+
status="success" if case.success else "failed",
|
|
343
|
+
detail=case.summary or ("passed" if case.success else "failed"),
|
|
344
|
+
duration_ms=duration_ms,
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
shots = _abs_shots(case)
|
|
348
|
+
artifacts: dict[str, Any] = {}
|
|
349
|
+
if shots:
|
|
350
|
+
artifacts["screenshot"] = shots[0]
|
|
351
|
+
artifacts["screenshots"] = shots
|
|
352
|
+
if case.video_url:
|
|
353
|
+
artifacts["video_path"] = case.video_url
|
|
354
|
+
if case.artifact_dir:
|
|
355
|
+
artifacts["artifact_dir"] = case.artifact_dir
|
|
356
|
+
if case.flow_path:
|
|
357
|
+
artifacts["flow"] = case.flow_path
|
|
358
|
+
if case.provider:
|
|
359
|
+
artifacts["provider"] = case.provider
|
|
360
|
+
if case.device_id:
|
|
361
|
+
artifacts["device_id"] = case.device_id
|
|
362
|
+
if case.dashboard_url:
|
|
363
|
+
artifacts["dashboard_url"] = case.dashboard_url
|
|
364
|
+
if case.build_id:
|
|
365
|
+
artifacts["build_id"] = case.build_id
|
|
366
|
+
tag_list = list(tags or [])
|
|
367
|
+
if case.provider and case.provider not in tag_list:
|
|
368
|
+
tag_list.append(case.provider)
|
|
369
|
+
if case.platform and case.platform not in tag_list:
|
|
370
|
+
tag_list.append(case.platform)
|
|
371
|
+
errors = [e for e in (case.error, case.summary) if e and not case.success]
|
|
372
|
+
return CaseRecord(
|
|
373
|
+
id=case.name,
|
|
374
|
+
name=case.name,
|
|
375
|
+
status=status,
|
|
376
|
+
mode=mode,
|
|
377
|
+
url=(app_id or "").strip() or (case.platform or ""),
|
|
378
|
+
tags=tag_list,
|
|
379
|
+
title=(case.task or case.name).split("\n", 1)[0][:160],
|
|
380
|
+
started_at=started,
|
|
381
|
+
finished_at=utc_now_iso(),
|
|
382
|
+
duration_ms=duration_ms,
|
|
383
|
+
phases=phases,
|
|
384
|
+
steps=steps,
|
|
385
|
+
explore_usage=_token_usage_from(case.explore_usage),
|
|
386
|
+
codegen_usage=_token_usage_from(case.codegen_usage),
|
|
387
|
+
files_generated=[case.flow_path] if case.flow_path else [],
|
|
388
|
+
heal_attempts=max(0, int(heal_attempts)),
|
|
389
|
+
failure_output=(case.stderr or case.stdout or case.error or "")[:20000],
|
|
390
|
+
errors=errors,
|
|
391
|
+
artifacts=artifacts,
|
|
392
|
+
source_case=source_case,
|
|
393
|
+
prompt=case.task or None,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def build_insights(cases: list[CaseRecord], summary: PackSummary) -> list[Insight]:
|
|
398
|
+
insights: list[Insight] = []
|
|
399
|
+
if summary.total == 0:
|
|
400
|
+
insights.append(
|
|
401
|
+
Insight(
|
|
402
|
+
severity="warn",
|
|
403
|
+
title="No cases in pack",
|
|
404
|
+
body="Run mobiflow against one or more cases, then regenerate the report.",
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
return insights
|
|
408
|
+
if summary.failed or summary.error:
|
|
409
|
+
insights.append(
|
|
410
|
+
Insight(
|
|
411
|
+
severity="fail",
|
|
412
|
+
title=f"{summary.failed + summary.error} failing case(s)",
|
|
413
|
+
body="Open Failures for stderr, screenshots, and heal attempts.",
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
else:
|
|
417
|
+
insights.append(
|
|
418
|
+
Insight(
|
|
419
|
+
severity="pass",
|
|
420
|
+
title=f"Pass rate {summary.pass_rate:.0f}%",
|
|
421
|
+
body="All cases passed in this pack.",
|
|
422
|
+
)
|
|
423
|
+
)
|
|
424
|
+
healed = [c for c in cases if c.heal_attempts > 0]
|
|
425
|
+
if healed:
|
|
426
|
+
insights.append(
|
|
427
|
+
Insight(
|
|
428
|
+
severity="warn",
|
|
429
|
+
title=f"{len(healed)} case(s) needed heal/retry",
|
|
430
|
+
body="Review Flaky Tests for instability signals.",
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
return insights
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def build_pack(
|
|
437
|
+
cases: list[CaseRecord],
|
|
438
|
+
*,
|
|
439
|
+
title: str = "MobiFlow Execution Report",
|
|
440
|
+
env: EnvInfo | None = None,
|
|
441
|
+
pack_id: str | None = None,
|
|
442
|
+
) -> ReportPack:
|
|
443
|
+
passed = sum(1 for c in cases if c.status == "passed")
|
|
444
|
+
failed = sum(1 for c in cases if c.status == "failed")
|
|
445
|
+
skipped = sum(1 for c in cases if c.status == "skipped")
|
|
446
|
+
error = sum(1 for c in cases if c.status == "error")
|
|
447
|
+
total = len(cases)
|
|
448
|
+
duration_ms = sum(int(c.duration_ms or 0) for c in cases)
|
|
449
|
+
usage = TokenUsage()
|
|
450
|
+
for c in cases:
|
|
451
|
+
usage = usage.merged(c.total_usage)
|
|
452
|
+
pass_rate = (100.0 * passed / total) if total else 0.0
|
|
453
|
+
summary = PackSummary(
|
|
454
|
+
total=total,
|
|
455
|
+
passed=passed,
|
|
456
|
+
failed=failed,
|
|
457
|
+
skipped=skipped,
|
|
458
|
+
error=error,
|
|
459
|
+
duration_ms=duration_ms,
|
|
460
|
+
prompt_tokens=usage.prompt_tokens,
|
|
461
|
+
completion_tokens=usage.completion_tokens,
|
|
462
|
+
total_tokens=usage.total_tokens,
|
|
463
|
+
cost=usage.cost,
|
|
464
|
+
pass_rate=pass_rate,
|
|
465
|
+
)
|
|
466
|
+
env_info = env or EnvInfo(
|
|
467
|
+
python=platform.python_version(),
|
|
468
|
+
platform=f"{platform.system()} {platform.release()} ({platform.machine()})",
|
|
469
|
+
stack_tool="maestro",
|
|
470
|
+
stack_runner="maestro",
|
|
471
|
+
)
|
|
472
|
+
if not env_info.python:
|
|
473
|
+
env_info.python = platform.python_version()
|
|
474
|
+
pack = ReportPack(
|
|
475
|
+
id=pack_id or uuid.uuid4().hex[:8],
|
|
476
|
+
title=title,
|
|
477
|
+
generated_at=utc_now_iso(),
|
|
478
|
+
env=env_info,
|
|
479
|
+
summary=summary,
|
|
480
|
+
cases=list(cases),
|
|
481
|
+
trends=[],
|
|
482
|
+
insights=build_insights(cases, summary),
|
|
483
|
+
)
|
|
484
|
+
return pack
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _detect_maestro_version() -> str:
|
|
488
|
+
import re
|
|
489
|
+
import shutil
|
|
490
|
+
import subprocess
|
|
491
|
+
|
|
492
|
+
binary = shutil.which("maestro")
|
|
493
|
+
if not binary:
|
|
494
|
+
return ""
|
|
495
|
+
try:
|
|
496
|
+
proc = subprocess.run(
|
|
497
|
+
[binary, "--version"],
|
|
498
|
+
capture_output=True,
|
|
499
|
+
text=True,
|
|
500
|
+
timeout=8,
|
|
501
|
+
check=False,
|
|
502
|
+
)
|
|
503
|
+
text = (proc.stdout or proc.stderr or "").strip()
|
|
504
|
+
if not text:
|
|
505
|
+
return ""
|
|
506
|
+
first = text.splitlines()[0].strip()
|
|
507
|
+
# Ignore JVM / install errors masquerading as version output
|
|
508
|
+
if re.search(r"(?i)unable to locate|error|exception|not found", first):
|
|
509
|
+
return ""
|
|
510
|
+
if not re.search(r"\d+\.\d+", first):
|
|
511
|
+
return ""
|
|
512
|
+
return first[:120]
|
|
513
|
+
except (OSError, subprocess.SubprocessError):
|
|
514
|
+
return ""
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def env_from_config(
|
|
518
|
+
cfg: Any | None = None,
|
|
519
|
+
*,
|
|
520
|
+
device_label: str = "",
|
|
521
|
+
app_id: str = "",
|
|
522
|
+
mobile_platform: str = "",
|
|
523
|
+
device_provider: str = "",
|
|
524
|
+
) -> EnvInfo:
|
|
525
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
526
|
+
|
|
527
|
+
try:
|
|
528
|
+
ver = version("mobiflow")
|
|
529
|
+
except PackageNotFoundError:
|
|
530
|
+
ver = "0.2.0"
|
|
531
|
+
llm_provider = ""
|
|
532
|
+
lang = "yaml+js"
|
|
533
|
+
repo = ""
|
|
534
|
+
if cfg is not None:
|
|
535
|
+
try:
|
|
536
|
+
repo = str(cfg.repo_path())
|
|
537
|
+
lang = str(getattr(cfg.stack, "language", lang) or lang)
|
|
538
|
+
try:
|
|
539
|
+
prof = cfg.codegen_profile()
|
|
540
|
+
llm_provider = str(getattr(prof, "provider", "") or "")
|
|
541
|
+
except Exception: # noqa: BLE001
|
|
542
|
+
pass
|
|
543
|
+
app_id = app_id or str(getattr(cfg.device, "app_id", "") or "")
|
|
544
|
+
device_label = device_label or str(
|
|
545
|
+
getattr(cfg.device, "device_id", "") or ""
|
|
546
|
+
)
|
|
547
|
+
mobile_platform = mobile_platform or str(
|
|
548
|
+
getattr(cfg.device, "platform", "") or ""
|
|
549
|
+
)
|
|
550
|
+
device_provider = device_provider or str(
|
|
551
|
+
getattr(cfg.device, "provider", "") or "local"
|
|
552
|
+
)
|
|
553
|
+
except Exception: # noqa: BLE001
|
|
554
|
+
pass
|
|
555
|
+
return EnvInfo(
|
|
556
|
+
python=platform.python_version(),
|
|
557
|
+
platform=f"{platform.system()} {platform.release()} ({platform.machine()})",
|
|
558
|
+
mobiflow_version=ver,
|
|
559
|
+
maestro=_detect_maestro_version(),
|
|
560
|
+
llm_provider=llm_provider,
|
|
561
|
+
stack_tool="maestro",
|
|
562
|
+
stack_language=lang,
|
|
563
|
+
stack_runner="maestro",
|
|
564
|
+
repo_path=repo,
|
|
565
|
+
device=device_label,
|
|
566
|
+
app_id=app_id,
|
|
567
|
+
mobile_platform=mobile_platform,
|
|
568
|
+
device_provider=device_provider or "local",
|
|
569
|
+
browser=device_label,
|
|
570
|
+
url=app_id,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def write_rich_reports(
|
|
575
|
+
cases: list[ReportCase],
|
|
576
|
+
report_dir: Path,
|
|
577
|
+
*,
|
|
578
|
+
title: str = "MobiFlow Execution Report",
|
|
579
|
+
cfg: Any | None = None,
|
|
580
|
+
app_id: str = "",
|
|
581
|
+
) -> dict[str, str]:
|
|
582
|
+
"""Write pack.json + SPA index.html into report_dir."""
|
|
583
|
+
records = [
|
|
584
|
+
case_record_from_report_case(
|
|
585
|
+
c,
|
|
586
|
+
app_id=app_id,
|
|
587
|
+
mode="scripted" if c.synthesis_only else "ai",
|
|
588
|
+
)
|
|
589
|
+
for c in cases
|
|
590
|
+
]
|
|
591
|
+
device = next((c.device_id for c in cases if c.device_id), "")
|
|
592
|
+
mobile_platform = next((c.platform for c in cases if c.platform), "")
|
|
593
|
+
device_provider = next((c.provider for c in cases if c.provider), "local")
|
|
594
|
+
if not app_id:
|
|
595
|
+
# Prefer real app ids over platform labels stuffed into case.url
|
|
596
|
+
for rec in records:
|
|
597
|
+
if rec.url and rec.url.lower() not in {"ios", "android"}:
|
|
598
|
+
app_id = rec.url
|
|
599
|
+
break
|
|
600
|
+
pack = build_pack(
|
|
601
|
+
records,
|
|
602
|
+
title=title,
|
|
603
|
+
env=env_from_config(
|
|
604
|
+
cfg,
|
|
605
|
+
device_label=device,
|
|
606
|
+
app_id=app_id,
|
|
607
|
+
mobile_platform=mobile_platform,
|
|
608
|
+
device_provider=device_provider,
|
|
609
|
+
),
|
|
610
|
+
)
|
|
611
|
+
report_dir = Path(report_dir)
|
|
612
|
+
report_dir.mkdir(parents=True, exist_ok=True)
|
|
613
|
+
html_path = write_pack_html(pack, report_dir / "index.html")
|
|
614
|
+
json_path = write_pack_json(pack, report_dir / "pack.json")
|
|
615
|
+
# Also mirror as report.html for existing links
|
|
616
|
+
(report_dir / "report.html").write_text(html_path.read_text(encoding="utf-8"), encoding="utf-8")
|
|
617
|
+
return {"html": str(html_path), "json": str(json_path), "report": str(report_dir / "report.html")}
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-49.64" y="2.03" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-45.045" y="20.029" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-43.513" y="21.178" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="15.756" y="-17.901" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-27.636" y="-22.853" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="20.116" y="-38.415" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="24.641" y="-11.323" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="8.244" y="-2.416" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="18.713" y="10.588" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter></defs></svg>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
|
3
|
+
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
|
4
|
+
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
|
5
|
+
</symbol>
|
|
6
|
+
<symbol id="discord-icon" viewBox="0 0 20 19">
|
|
7
|
+
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
|
8
|
+
</symbol>
|
|
9
|
+
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
|
10
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
|
11
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
|
12
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
|
13
|
+
</symbol>
|
|
14
|
+
<symbol id="github-icon" viewBox="0 0 19 19">
|
|
15
|
+
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
|
16
|
+
</symbol>
|
|
17
|
+
<symbol id="social-icon" viewBox="0 0 20 20">
|
|
18
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
|
19
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
|
20
|
+
</symbol>
|
|
21
|
+
<symbol id="x-icon" viewBox="0 0 19 19">
|
|
22
|
+
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
|
23
|
+
</symbol>
|
|
24
|
+
</svg>
|