@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,159 @@
|
|
|
1
|
+
"""External test data files for cases (``data: path``).
|
|
2
|
+
|
|
3
|
+
Supported formats: ``.json``, ``.yaml`` / ``.yml``, ``.env``.
|
|
4
|
+
Values are flattened to string env vars for Maestro ``--env`` / ``${KEY}``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
_ENV_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve_data_path(
|
|
18
|
+
raw: str,
|
|
19
|
+
*,
|
|
20
|
+
case_path: Path | None = None,
|
|
21
|
+
repo: Path | None = None,
|
|
22
|
+
) -> Path:
|
|
23
|
+
"""Resolve relative/absolute data path.
|
|
24
|
+
|
|
25
|
+
Order for relative paths:
|
|
26
|
+
1. Beside the case file
|
|
27
|
+
2. Project repo root
|
|
28
|
+
3. CWD
|
|
29
|
+
"""
|
|
30
|
+
text = (raw or "").strip().strip("\"'")
|
|
31
|
+
if not text:
|
|
32
|
+
raise ValueError("data: path is empty")
|
|
33
|
+
p = Path(text).expanduser()
|
|
34
|
+
if p.is_absolute():
|
|
35
|
+
if not p.is_file():
|
|
36
|
+
raise FileNotFoundError(f"Data file not found: {p}")
|
|
37
|
+
return p.resolve()
|
|
38
|
+
|
|
39
|
+
candidates: list[Path] = []
|
|
40
|
+
if case_path is not None:
|
|
41
|
+
candidates.append((case_path.parent / p).resolve())
|
|
42
|
+
if repo is not None:
|
|
43
|
+
candidates.append((Path(repo).resolve() / p).resolve())
|
|
44
|
+
candidates.append((Path.cwd() / p).resolve())
|
|
45
|
+
|
|
46
|
+
seen: set[str] = set()
|
|
47
|
+
for cand in candidates:
|
|
48
|
+
key = str(cand)
|
|
49
|
+
if key in seen:
|
|
50
|
+
continue
|
|
51
|
+
seen.add(key)
|
|
52
|
+
if cand.is_file():
|
|
53
|
+
return cand
|
|
54
|
+
raise FileNotFoundError(
|
|
55
|
+
f"Data file not found: {text} (tried: {', '.join(str(c) for c in candidates)})"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def flatten_data(obj: Any, *, prefix: str = "") -> dict[str, str]:
|
|
60
|
+
"""Flatten nested dict/list into UPPER_SNAKE Maestro env keys."""
|
|
61
|
+
out: dict[str, str] = {}
|
|
62
|
+
|
|
63
|
+
def _key(parts: list[str]) -> str:
|
|
64
|
+
raw = "_".join(parts)
|
|
65
|
+
raw = re.sub(r"[^A-Za-z0-9_]+", "_", raw)
|
|
66
|
+
raw = re.sub(r"_+", "_", raw).strip("_")
|
|
67
|
+
if not raw:
|
|
68
|
+
raw = "VALUE"
|
|
69
|
+
if raw[0].isdigit():
|
|
70
|
+
raw = f"N_{raw}"
|
|
71
|
+
return raw.upper()
|
|
72
|
+
|
|
73
|
+
def walk(node: Any, parts: list[str]) -> None:
|
|
74
|
+
if isinstance(node, dict):
|
|
75
|
+
for k, v in node.items():
|
|
76
|
+
walk(v, parts + [str(k)])
|
|
77
|
+
return
|
|
78
|
+
if isinstance(node, list):
|
|
79
|
+
for i, v in enumerate(node):
|
|
80
|
+
walk(v, parts + [str(i)])
|
|
81
|
+
return
|
|
82
|
+
if node is None:
|
|
83
|
+
return
|
|
84
|
+
if isinstance(node, bool):
|
|
85
|
+
out[_key(parts)] = "true" if node else "false"
|
|
86
|
+
return
|
|
87
|
+
out[_key(parts)] = str(node)
|
|
88
|
+
|
|
89
|
+
root_parts = [prefix] if prefix else []
|
|
90
|
+
if isinstance(obj, dict):
|
|
91
|
+
walk(obj, root_parts)
|
|
92
|
+
elif isinstance(obj, list):
|
|
93
|
+
# Prefer first object row for single-record data files
|
|
94
|
+
if obj and isinstance(obj[0], dict) and len(obj) == 1:
|
|
95
|
+
walk(obj[0], root_parts)
|
|
96
|
+
else:
|
|
97
|
+
walk(obj, root_parts or ["ITEM"])
|
|
98
|
+
else:
|
|
99
|
+
walk(obj, root_parts or ["VALUE"])
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load_data_file(path: Path) -> tuple[dict[str, Any], dict[str, str]]:
|
|
104
|
+
"""Load a data file → (raw object, flattened string env map)."""
|
|
105
|
+
path = Path(path).resolve()
|
|
106
|
+
text = path.read_text(encoding="utf-8")
|
|
107
|
+
suffix = path.suffix.lower()
|
|
108
|
+
|
|
109
|
+
if suffix == ".json":
|
|
110
|
+
raw: Any = json.loads(text) if text.strip() else {}
|
|
111
|
+
elif suffix in {".yaml", ".yml"}:
|
|
112
|
+
import yaml
|
|
113
|
+
|
|
114
|
+
raw = yaml.safe_load(text) if text.strip() else {}
|
|
115
|
+
elif suffix == ".env" or path.name.startswith(".env"):
|
|
116
|
+
raw = {}
|
|
117
|
+
for line in text.splitlines():
|
|
118
|
+
s = line.strip()
|
|
119
|
+
if not s or s.startswith("#"):
|
|
120
|
+
continue
|
|
121
|
+
if s.startswith("export "):
|
|
122
|
+
s = s[7:].strip()
|
|
123
|
+
m = _ENV_LINE.match(s)
|
|
124
|
+
if not m:
|
|
125
|
+
continue
|
|
126
|
+
val = m.group(2).strip().strip("\"'")
|
|
127
|
+
raw[m.group(1)] = val
|
|
128
|
+
else:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"Unsupported data file type '{suffix or path.name}'. "
|
|
131
|
+
"Use .json, .yaml/.yml, or .env"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if raw is None:
|
|
135
|
+
raw = {}
|
|
136
|
+
flat = flatten_data(raw)
|
|
137
|
+
return (raw if isinstance(raw, dict) else {"data": raw}), flat
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def format_data_prompt_block(
|
|
141
|
+
flat: dict[str, str],
|
|
142
|
+
*,
|
|
143
|
+
path: str = "",
|
|
144
|
+
limit: int = 40,
|
|
145
|
+
) -> str:
|
|
146
|
+
"""Compact block injected into explore/codegen goals."""
|
|
147
|
+
if not flat:
|
|
148
|
+
return ""
|
|
149
|
+
lines = [f"{k}={v}" for k, v in sorted(flat.items())[:limit]]
|
|
150
|
+
more = ""
|
|
151
|
+
if len(flat) > limit:
|
|
152
|
+
more = f"\n… ({len(flat) - limit} more keys)"
|
|
153
|
+
head = f"Test data from {path}:" if path else "Test data:"
|
|
154
|
+
return (
|
|
155
|
+
f"{head}\n"
|
|
156
|
+
"Use these values via Maestro ${KEY} / --env (do not hardcode secrets):\n"
|
|
157
|
+
+ "\n".join(lines)
|
|
158
|
+
+ more
|
|
159
|
+
)
|