@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,259 @@
1
+ """Download and install FOSS sample apps (Wikipedia, Joplin) onto a connected device.
2
+
3
+ Binaries are **not** shipped in the npm/git package (too large). They download
4
+ into ``builds/`` on demand from official Wikimedia / GitHub releases.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import re
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+ from urllib.error import HTTPError, URLError
16
+ from urllib.request import Request, urlopen
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ WIKIPEDIA_STABLE_INDEX = (
21
+ "https://releases.wikimedia.org/mobile/android/wikipedia/stable/"
22
+ )
23
+ JOPLIN_RELEASES_API = (
24
+ "https://api.github.com/repos/laurent22/joplin-android/releases?per_page=15"
25
+ )
26
+
27
+ _APK_RE = re.compile(
28
+ r'href="(wikipedia-\d+-r-\d{4}-\d{2}-\d{2}\.apk)"',
29
+ re.IGNORECASE,
30
+ )
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class SampleApp:
35
+ name: str
36
+ app_id_android: str
37
+ app_id_ios: str
38
+ label: str
39
+ notes: str
40
+
41
+
42
+ CATALOG: dict[str, SampleApp] = {
43
+ "wikipedia": SampleApp(
44
+ name="wikipedia",
45
+ app_id_android="org.wikipedia",
46
+ app_id_ios="org.wikimedia.wikipedia",
47
+ label="Wikipedia (Android APK from Wikimedia releases)",
48
+ notes=(
49
+ "Android: downloads stable APK. "
50
+ "iOS Simulator: App Store IPA cannot be sideloaded — "
51
+ "pass --app path/to/Wikipedia.app from a local Xcode build."
52
+ ),
53
+ ),
54
+ "joplin": SampleApp(
55
+ name="joplin",
56
+ app_id_android="net.cozic.joplin",
57
+ app_id_ios="net.cozic.joplin",
58
+ label="Joplin notes (Android APK from GitHub releases)",
59
+ notes=(
60
+ "Android: downloads universal APK. "
61
+ "iOS: pass --app path/to/Joplin.app from a local build."
62
+ ),
63
+ ),
64
+ }
65
+
66
+
67
+ def list_sample_apps() -> list[SampleApp]:
68
+ return [CATALOG[k] for k in sorted(CATALOG)]
69
+
70
+
71
+ def get_sample_app(name: str) -> SampleApp:
72
+ key = (name or "").strip().lower()
73
+ if key not in CATALOG:
74
+ known = ", ".join(sorted(CATALOG))
75
+ raise ValueError(f"Unknown sample app {name!r}. Known: {known}")
76
+ return CATALOG[key]
77
+
78
+
79
+ def default_builds_dir(repo: Path | None = None) -> Path:
80
+ root = Path(repo).expanduser().resolve() if repo else Path.cwd()
81
+ return root / "builds"
82
+
83
+
84
+ def _http_get(url: str, *, timeout: float = 60.0) -> bytes:
85
+ req = Request(
86
+ url,
87
+ headers={
88
+ "User-Agent": "MobiFlow/sample-apps",
89
+ "Accept": "*/*",
90
+ },
91
+ )
92
+ with urlopen(req, timeout=timeout) as resp: # noqa: S310 — fixed HTTPS URLs
93
+ return resp.read()
94
+
95
+
96
+ def resolve_wikipedia_apk_url(html: str | None = None) -> str:
97
+ """Pick the newest ``wikipedia-*-r-YYYY-MM-DD.apk`` from Wikimedia stable."""
98
+ if html is None:
99
+ html = _http_get(WIKIPEDIA_STABLE_INDEX, timeout=30.0).decode(
100
+ "utf-8", errors="replace"
101
+ )
102
+ names = sorted(set(_APK_RE.findall(html)), reverse=True)
103
+ if not names:
104
+ names = sorted(
105
+ set(
106
+ re.findall(
107
+ r"(wikipedia-\d+-r-\d{4}-\d{2}-\d{2}\.apk)",
108
+ html,
109
+ flags=re.IGNORECASE,
110
+ )
111
+ ),
112
+ reverse=True,
113
+ )
114
+ if not names:
115
+ raise RuntimeError(
116
+ f"No Wikipedia APK found at {WIKIPEDIA_STABLE_INDEX}"
117
+ )
118
+ return WIKIPEDIA_STABLE_INDEX.rstrip("/") + "/" + names[0]
119
+
120
+
121
+ def resolve_joplin_apk_url(payload: Any = None) -> str:
122
+ """Latest non-prerelease Joplin Android APK from GitHub releases."""
123
+ if payload is None:
124
+ raw = _http_get(JOPLIN_RELEASES_API, timeout=30.0)
125
+ payload = json.loads(raw.decode("utf-8"))
126
+ if not isinstance(payload, list):
127
+ raise RuntimeError("Unexpected GitHub releases payload for Joplin")
128
+ for rel in payload:
129
+ if rel.get("prerelease"):
130
+ continue
131
+ for asset in rel.get("assets") or []:
132
+ name = str(asset.get("name") or "")
133
+ url = str(asset.get("browser_download_url") or "")
134
+ if name.endswith(".apk") and url:
135
+ return url
136
+ for rel in payload:
137
+ for asset in rel.get("assets") or []:
138
+ name = str(asset.get("name") or "")
139
+ url = str(asset.get("browser_download_url") or "")
140
+ if name.endswith(".apk") and url:
141
+ return url
142
+ raise RuntimeError("No Joplin APK asset found on GitHub releases")
143
+
144
+
145
+ def resolve_download_url(name: str) -> str:
146
+ app = get_sample_app(name)
147
+ if app.name == "wikipedia":
148
+ return resolve_wikipedia_apk_url()
149
+ if app.name == "joplin":
150
+ return resolve_joplin_apk_url()
151
+ raise ValueError(f"No Android download URL for {app.name}")
152
+
153
+
154
+ def download_sample_apk(
155
+ name: str,
156
+ *,
157
+ dest_dir: Path | None = None,
158
+ force: bool = False,
159
+ progress: Any = None,
160
+ ) -> Path:
161
+ """Download sample APK into ``builds/<name>.apk`` (cached unless ``force``)."""
162
+ app = get_sample_app(name)
163
+ out_dir = Path(dest_dir) if dest_dir else default_builds_dir()
164
+ out_dir.mkdir(parents=True, exist_ok=True)
165
+ dest = out_dir / f"{app.name}.apk"
166
+ if dest.is_file() and dest.stat().st_size > 1_000_000 and not force:
167
+ if progress:
168
+ progress(f"Using cached APK: {dest}")
169
+ return dest
170
+
171
+ url = resolve_download_url(app.name)
172
+ if progress:
173
+ progress(f"Downloading {app.label}…")
174
+ progress(f" {url}")
175
+ try:
176
+ data = _http_get(url, timeout=300.0)
177
+ except (HTTPError, URLError, TimeoutError) as e:
178
+ raise RuntimeError(f"Download failed for {app.name}: {e}") from e
179
+ if len(data) < 100_000:
180
+ raise RuntimeError(
181
+ f"Downloaded file too small ({len(data)} bytes) — unexpected response"
182
+ )
183
+ tmp = dest.with_suffix(".apk.partial")
184
+ tmp.write_bytes(data)
185
+ tmp.replace(dest)
186
+ if progress:
187
+ progress(f"Saved {dest} ({len(data) // (1024 * 1024)} MB)")
188
+ return dest
189
+
190
+
191
+ async def install_sample_app(
192
+ name: str,
193
+ *,
194
+ platform: str = "android",
195
+ device_id: str | None = None,
196
+ apk_path: str | Path | None = None,
197
+ app_path: str | Path | None = None,
198
+ dest_dir: Path | None = None,
199
+ download_only: bool = False,
200
+ force_download: bool = False,
201
+ progress: Any = None,
202
+ ) -> dict[str, Any]:
203
+ """Download (Android) and/or install a sample app onto a connected device."""
204
+ from mobiflow.maestro.lifecycle import install_app_local
205
+
206
+ app = get_sample_app(name)
207
+ plat = (platform or "android").lower()
208
+ result: dict[str, Any] = {
209
+ "ok": False,
210
+ "app": app.name,
211
+ "platform": plat,
212
+ "app_id": app.app_id_android if plat == "android" else app.app_id_ios,
213
+ }
214
+
215
+ package: Path | None = None
216
+ if apk_path:
217
+ package = Path(apk_path).expanduser().resolve()
218
+ elif app_path:
219
+ package = Path(app_path).expanduser().resolve()
220
+ elif plat == "android":
221
+ package = download_sample_apk(
222
+ app.name,
223
+ dest_dir=dest_dir,
224
+ force=force_download,
225
+ progress=progress,
226
+ )
227
+ result["apk_path"] = str(package)
228
+ else:
229
+ result["error"] = "ios_needs_app_bundle"
230
+ result["message"] = (
231
+ f"iOS: no store IPA sideload. Build {app.name} locally and pass "
232
+ f"--app path/to/{app.name}.app (simctl install). {app.notes}"
233
+ )
234
+ return result
235
+
236
+ if download_only:
237
+ result["ok"] = True
238
+ result["message"] = f"Downloaded to {package}"
239
+ result["apk_path"] = str(package)
240
+ return result
241
+
242
+ if package is None:
243
+ result["error"] = "no_package"
244
+ result["message"] = "No APK/.app path to install"
245
+ return result
246
+
247
+ if progress:
248
+ progress(f"Installing {app.name} on {device_id or 'default device'}…")
249
+ installed = await install_app_local(
250
+ package,
251
+ device_id=device_id,
252
+ platform=plat,
253
+ )
254
+ result.update(installed)
255
+ if installed.get("ok"):
256
+ result["message"] = (
257
+ f"Installed {app.name} ({result['app_id']}) via {package}"
258
+ )
259
+ return result
@@ -0,0 +1,90 @@
1
+ """Env injection for Maestro and secret redaction in reports/logs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from typing import Any
8
+
9
+ _SECRET_KEY_RE = re.compile(
10
+ r"(password|passwd|secret|token|api[_-]?key|access[_-]?key|auth|credential)",
11
+ re.IGNORECASE,
12
+ )
13
+
14
+
15
+ def merge_flow_env(
16
+ *maps: dict[str, str] | None,
17
+ resolve_from_process: bool = True,
18
+ ) -> dict[str, str]:
19
+ """Merge env maps; values that look like ENV NAMES are resolved from the process.
20
+
21
+ If a value matches ``^[A-Z_][A-Z0-9_]*$`` and exists in ``os.environ``, the
22
+ process value is used (so config can store ``PASSWORD: MOBIFLOW_PASSWORD``).
23
+ """
24
+ out: dict[str, str] = {}
25
+ for m in maps:
26
+ if not m:
27
+ continue
28
+ for key, val in m.items():
29
+ k = str(key).strip()
30
+ if not k:
31
+ continue
32
+ v = "" if val is None else str(val)
33
+ if (
34
+ resolve_from_process
35
+ and re.fullmatch(r"[A-Z_][A-Z0-9_]*", v)
36
+ and v in os.environ
37
+ ):
38
+ out[k] = os.environ[v]
39
+ continue
40
+ # Also: empty value + key present in process → use process
41
+ if resolve_from_process and not v and k in os.environ:
42
+ out[k] = os.environ[k]
43
+ continue
44
+ out[k] = v
45
+ return out
46
+
47
+
48
+ def maestro_env_args(env: dict[str, str]) -> list[str]:
49
+ """Build ``--env KEY=VALUE`` argv fragments for Maestro CLI."""
50
+ args: list[str] = []
51
+ for key, val in sorted(env.items()):
52
+ args.extend(["--env", f"{key}={val}"])
53
+ return args
54
+
55
+
56
+ def is_secret_key(name: str) -> bool:
57
+ return bool(_SECRET_KEY_RE.search(name or ""))
58
+
59
+
60
+ def redact_text(text: str, secrets: dict[str, str] | None = None) -> str:
61
+ """Replace known secret values (and obvious key=value pairs) in text."""
62
+ if not text:
63
+ return text
64
+ out = text
65
+ for key, val in (secrets or {}).items():
66
+ if not val or len(val) < 4:
67
+ continue
68
+ if is_secret_key(key) or len(val) >= 8:
69
+ out = out.replace(val, "***")
70
+ # Generic KEY=secret patterns for common names
71
+ out = re.sub(
72
+ r"(?i)\b(password|passwd|secret|token|api[_-]?key|access[_-]?key)\s*[=:]\s*\S+",
73
+ r"\1=***",
74
+ out,
75
+ )
76
+ return out
77
+
78
+
79
+ def redact_mapping(data: dict[str, Any], secrets: dict[str, str] | None = None) -> dict[str, Any]:
80
+ """Shallow-redact string values in a dict for JSON reports."""
81
+ out: dict[str, Any] = {}
82
+ for k, v in data.items():
83
+ if isinstance(v, str):
84
+ if is_secret_key(str(k)):
85
+ out[k] = "***"
86
+ else:
87
+ out[k] = redact_text(v, secrets)
88
+ else:
89
+ out[k] = v
90
+ return out
@@ -0,0 +1,128 @@
1
+ """Durable selector memory across explore / codegen runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ def _safe_app_key(app_id: str) -> str:
13
+ raw = (app_id or "unknown").strip() or "unknown"
14
+ return re.sub(r"[^\w.\-]+", "_", raw)[:120]
15
+
16
+
17
+ def selectors_path(artifacts_dir: Path, app_id: str) -> Path:
18
+ return Path(artifacts_dir) / "selectors" / f"{_safe_app_key(app_id)}.json"
19
+
20
+
21
+ def load_selector_memory(artifacts_dir: Path, app_id: str) -> dict[str, Any]:
22
+ path = selectors_path(artifacts_dir, app_id)
23
+ if not path.is_file():
24
+ return {"app_id": app_id, "selectors": []}
25
+ try:
26
+ data = json.loads(path.read_text(encoding="utf-8"))
27
+ except (OSError, json.JSONDecodeError):
28
+ return {"app_id": app_id, "selectors": []}
29
+ if not isinstance(data, dict):
30
+ return {"app_id": app_id, "selectors": []}
31
+ data.setdefault("app_id", app_id)
32
+ data.setdefault("selectors", [])
33
+ return data
34
+
35
+
36
+ def save_selector_memory(
37
+ artifacts_dir: Path,
38
+ app_id: str,
39
+ memory: dict[str, Any],
40
+ ) -> Path:
41
+ path = selectors_path(artifacts_dir, app_id)
42
+ path.parent.mkdir(parents=True, exist_ok=True)
43
+ memory = dict(memory)
44
+ memory["app_id"] = app_id
45
+ memory["updated_at"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
46
+ path.write_text(json.dumps(memory, indent=2) + "\n", encoding="utf-8")
47
+ return path
48
+
49
+
50
+ def merge_selectors(
51
+ memory: dict[str, Any],
52
+ selectors: list[dict[str, Any]],
53
+ *,
54
+ success: bool = True,
55
+ ) -> dict[str, Any]:
56
+ """Upsert selectors; bump hits on success, demote on failure."""
57
+ existing = {
58
+ (str(s.get("label") or ""), str(s.get("text") or "")): s
59
+ for s in memory.get("selectors") or []
60
+ if isinstance(s, dict)
61
+ }
62
+ now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
63
+ for sel in selectors:
64
+ if not isinstance(sel, dict):
65
+ continue
66
+ label = str(sel.get("label") or "").strip()
67
+ text = str(sel.get("text") or "").strip()
68
+ if not text and not label:
69
+ continue
70
+ key = (label, text)
71
+ row = existing.get(key) or {
72
+ "label": label,
73
+ "text": text,
74
+ "hits": 0,
75
+ "misses": 0,
76
+ }
77
+ if success:
78
+ row["hits"] = int(row.get("hits") or 0) + 1
79
+ row["last_success"] = now
80
+ else:
81
+ row["misses"] = int(row.get("misses") or 0) + 1
82
+ row["last_miss"] = now
83
+ existing[key] = row
84
+ # Prefer high-hit selectors
85
+ ordered = sorted(
86
+ existing.values(),
87
+ key=lambda s: (int(s.get("hits") or 0) - int(s.get("misses") or 0)),
88
+ reverse=True,
89
+ )
90
+ memory["selectors"] = ordered[:80]
91
+ return memory
92
+
93
+
94
+ def memory_to_prompt_block(memory: dict[str, Any], *, limit: int = 20) -> str:
95
+ sels = list(memory.get("selectors") or [])[:limit]
96
+ if not sels:
97
+ return ""
98
+ lines = ["Known working selectors (from prior runs):"]
99
+ for s in sels:
100
+ if int(s.get("hits") or 0) <= 0 and int(s.get("misses") or 0) > 0:
101
+ continue
102
+ label = s.get("label") or "-"
103
+ text = s.get("text") or "-"
104
+ hits = s.get("hits") or 0
105
+ lines.append(f"- {label} → {text} (hits={hits})")
106
+ if len(lines) == 1:
107
+ return ""
108
+ return "\n".join(lines)
109
+
110
+
111
+ def ensure_expect_asserts(flow_yaml: str, expect: list[str]) -> str:
112
+ """Append assertVisible lines for expect texts not already present."""
113
+ texts = [t.strip() for t in expect if t and str(t).strip()]
114
+ if not texts:
115
+ return flow_yaml
116
+ body = flow_yaml or ""
117
+ additions: list[str] = []
118
+ for text in texts:
119
+ needle = f'assertVisible: "{text}"'
120
+ needle2 = f"assertVisible: {text}"
121
+ if needle in body or needle2 in body:
122
+ continue
123
+ additions.append(f'- assertVisible: "{text}"')
124
+ if not additions:
125
+ return body
126
+ if not body.endswith("\n"):
127
+ body += "\n"
128
+ return body + "\n".join(additions) + "\n"