@qubiqlabs/mobiflow 0.9.1 → 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 +73 -28
  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,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
+ )