@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,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"
|