android-driver 0.0.1__py3-none-any.whl
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.
- android_driver/__init__.py +3 -0
- android_driver/actions.py +209 -0
- android_driver/adb.py +355 -0
- android_driver/build.py +81 -0
- android_driver/config.py +211 -0
- android_driver/drivers/__init__.py +22 -0
- android_driver/drivers/adb_driver.py +104 -0
- android_driver/drivers/base.py +146 -0
- android_driver/drivers/factory.py +29 -0
- android_driver/drivers/u2_driver.py +100 -0
- android_driver/emulator.py +277 -0
- android_driver/expect.py +190 -0
- android_driver/log.py +16 -0
- android_driver/recipes.py +547 -0
- android_driver/record.py +112 -0
- android_driver/run.py +292 -0
- android_driver/scan.py +155 -0
- android_driver/server.py +777 -0
- android_driver/session.py +144 -0
- android_driver/ui.py +261 -0
- android_driver-0.0.1.dist-info/METADATA +270 -0
- android_driver-0.0.1.dist-info/RECORD +25 -0
- android_driver-0.0.1.dist-info/WHEEL +4 -0
- android_driver-0.0.1.dist-info/entry_points.txt +2 -0
- android_driver-0.0.1.dist-info/licenses/LICENSE +21 -0
android_driver/run.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Run bundles: every artifact from one test attempt, in one directory.
|
|
2
|
+
|
|
3
|
+
An agent that finds a bug and then cannot show you *why* it believes that has
|
|
4
|
+
done half the work. A run collects the evidence as it goes — a timeline of every
|
|
5
|
+
action with its timing, a screenshot and hierarchy dump at each failure, and the
|
|
6
|
+
logcat slice for exactly that window — and writes a report a human can read
|
|
7
|
+
without replaying anything.
|
|
8
|
+
|
|
9
|
+
Artifacts are captured even with no run open: they land under
|
|
10
|
+
`runs/failures/<timestamp>/` instead. A failure you have to reproduce in order
|
|
11
|
+
to collect evidence for is a failure you have already half lost.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from . import adb
|
|
23
|
+
from .config import Config
|
|
24
|
+
from .log import log
|
|
25
|
+
from .session import Session
|
|
26
|
+
|
|
27
|
+
_SLUG_OK = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _slug(value: str, limit: int = 40) -> str:
|
|
31
|
+
cleaned = "".join(c if c in _SLUG_OK else "-" for c in value.strip().lower())
|
|
32
|
+
while "--" in cleaned:
|
|
33
|
+
cleaned = cleaned.replace("--", "-")
|
|
34
|
+
return cleaned.strip("-")[:limit] or "run"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Event:
|
|
39
|
+
t: float
|
|
40
|
+
tool: str
|
|
41
|
+
status: str
|
|
42
|
+
duration_s: float
|
|
43
|
+
detail: dict[str, Any] = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
def to_dict(self) -> dict[str, Any]:
|
|
46
|
+
return {
|
|
47
|
+
"t": round(self.t, 3),
|
|
48
|
+
"tool": self.tool,
|
|
49
|
+
"status": self.status,
|
|
50
|
+
"duration_s": self.duration_s,
|
|
51
|
+
**({"detail": self.detail} if self.detail else {}),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Run:
|
|
56
|
+
"""One test attempt and the directory holding its evidence."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, root: Path, name: str, note: str = "") -> None:
|
|
59
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
60
|
+
self.name = name
|
|
61
|
+
self.note = note
|
|
62
|
+
self.id = f"{stamp}-{_slug(name)}"
|
|
63
|
+
self.dir = root / self.id
|
|
64
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
self.started_at = time.time()
|
|
66
|
+
self.started_mono = time.monotonic()
|
|
67
|
+
self.events: list[Event] = []
|
|
68
|
+
self.device: dict[str, str] = {}
|
|
69
|
+
self.recording: dict[str, Any] | None = None
|
|
70
|
+
self.finished = False
|
|
71
|
+
|
|
72
|
+
# ── recording events ─────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
def event(self, tool: str, status: str, duration_s: float, detail: dict[str, Any] | None = None) -> Event:
|
|
75
|
+
ev = Event(
|
|
76
|
+
t=time.monotonic() - self.started_mono,
|
|
77
|
+
tool=tool,
|
|
78
|
+
status=status,
|
|
79
|
+
duration_s=round(duration_s, 3),
|
|
80
|
+
detail=detail or {},
|
|
81
|
+
)
|
|
82
|
+
self.events.append(ev)
|
|
83
|
+
return ev
|
|
84
|
+
|
|
85
|
+
def artifact(self, name: str) -> Path:
|
|
86
|
+
path = self.dir / name
|
|
87
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
return path
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def failures(self) -> list[Event]:
|
|
92
|
+
return [e for e in self.events if e.status != "ok"]
|
|
93
|
+
|
|
94
|
+
# ── output ───────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def write_timeline(self) -> Path:
|
|
97
|
+
payload = {
|
|
98
|
+
"id": self.id,
|
|
99
|
+
"name": self.name,
|
|
100
|
+
"note": self.note,
|
|
101
|
+
"started_at": self.started_at,
|
|
102
|
+
"duration_s": round(time.monotonic() - self.started_mono, 2),
|
|
103
|
+
"device": self.device,
|
|
104
|
+
"recording": self.recording,
|
|
105
|
+
"events": [e.to_dict() for e in self.events],
|
|
106
|
+
}
|
|
107
|
+
path = self.dir / "timeline.json"
|
|
108
|
+
path.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
|
|
109
|
+
return path
|
|
110
|
+
|
|
111
|
+
def write_report(self) -> Path:
|
|
112
|
+
total = time.monotonic() - self.started_mono
|
|
113
|
+
failures = self.failures
|
|
114
|
+
lines = [
|
|
115
|
+
f"# Run {self.id}",
|
|
116
|
+
"",
|
|
117
|
+
f"- **name**: {self.name}",
|
|
118
|
+
f"- **started**: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(self.started_at))}",
|
|
119
|
+
f"- **duration**: {total:.1f}s",
|
|
120
|
+
f"- **steps**: {len(self.events)} ({len(failures)} failed)",
|
|
121
|
+
]
|
|
122
|
+
if self.note:
|
|
123
|
+
lines.append(f"- **note**: {self.note}")
|
|
124
|
+
for key in ("serial", "model", "android_version", "sdk", "screen"):
|
|
125
|
+
if self.device.get(key):
|
|
126
|
+
lines.append(f"- **{key}**: {self.device[key]}")
|
|
127
|
+
if self.recording:
|
|
128
|
+
lines.append(f"- **recording**: `{self.recording.get('path', '?')}`")
|
|
129
|
+
|
|
130
|
+
lines += ["", "## Timeline", "", "| t | step | status | took |", "|---:|---|---|---:|"]
|
|
131
|
+
for e in self.events:
|
|
132
|
+
lines.append(f"| {e.t:6.1f}s | `{e.tool}` | {e.status} | {e.duration_s:.2f}s |")
|
|
133
|
+
|
|
134
|
+
if failures:
|
|
135
|
+
lines += ["", "## Failures", ""]
|
|
136
|
+
for e in failures:
|
|
137
|
+
lines.append(f"### `{e.tool}` at {e.t:.1f}s")
|
|
138
|
+
lines.append("")
|
|
139
|
+
error = e.detail.get("error")
|
|
140
|
+
if error:
|
|
141
|
+
lines += ["```", str(error), "```", ""]
|
|
142
|
+
for key in ("screenshot", "hierarchy"):
|
|
143
|
+
if e.detail.get(key):
|
|
144
|
+
lines.append(f"- {key}: `{e.detail[key]}`")
|
|
145
|
+
lines.append("")
|
|
146
|
+
|
|
147
|
+
lines += ["", "## Artifacts", ""]
|
|
148
|
+
for path in sorted(self.dir.rglob("*")):
|
|
149
|
+
if path.is_file() and path.name != "report.md":
|
|
150
|
+
lines.append(f"- `{path.relative_to(self.dir)}`")
|
|
151
|
+
|
|
152
|
+
path = self.dir / "report.md"
|
|
153
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
154
|
+
return path
|
|
155
|
+
|
|
156
|
+
def write_logcat(self, serial: str, pkg: str | None) -> Path | None:
|
|
157
|
+
"""The log for this run's window. Assumes the buffer was cleared at the start."""
|
|
158
|
+
try:
|
|
159
|
+
lines = adb.logcat_dump(serial, lines=20000, buffers="main,crash")
|
|
160
|
+
except Exception as e:
|
|
161
|
+
log("run", f"could not capture logcat: {e}")
|
|
162
|
+
return None
|
|
163
|
+
path = self.dir / "logcat.txt"
|
|
164
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
165
|
+
if pkg:
|
|
166
|
+
app_lines = [line for line in lines if pkg in line]
|
|
167
|
+
if app_lines:
|
|
168
|
+
(self.dir / "logcat-app.txt").write_text("\n".join(app_lines) + "\n", encoding="utf-8")
|
|
169
|
+
return path
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class Runs:
|
|
173
|
+
"""Owns the current run and the fallback artifact directory."""
|
|
174
|
+
|
|
175
|
+
def __init__(self, cfg: Config) -> None:
|
|
176
|
+
self.cfg = cfg
|
|
177
|
+
self.current: Run | None = None
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def root(self) -> Path:
|
|
181
|
+
return self.cfg.runs_dir
|
|
182
|
+
|
|
183
|
+
def start(self, session: Session, name: str, note: str = "", clear_log: bool = True) -> dict[str, Any]:
|
|
184
|
+
if self.current is not None and not self.current.finished:
|
|
185
|
+
previous = self.current.id
|
|
186
|
+
self.end(session)
|
|
187
|
+
log("run", f"auto-closed the previous run {previous}")
|
|
188
|
+
run = Run(self.root, name, note)
|
|
189
|
+
try:
|
|
190
|
+
run.device = adb.device_info(session.serial)
|
|
191
|
+
except Exception as e:
|
|
192
|
+
log("run", f"could not read device info: {e}")
|
|
193
|
+
if clear_log:
|
|
194
|
+
try:
|
|
195
|
+
adb.logcat_clear(session.serial)
|
|
196
|
+
except Exception as e:
|
|
197
|
+
log("run", f"could not clear logcat: {e}")
|
|
198
|
+
self.current = run
|
|
199
|
+
log("run", f"started {run.id} → {run.dir}")
|
|
200
|
+
return {"run_id": run.id, "dir": str(run.dir)}
|
|
201
|
+
|
|
202
|
+
def end(self, session: Session) -> dict[str, Any]:
|
|
203
|
+
run = self.current
|
|
204
|
+
if run is None or run.finished:
|
|
205
|
+
raise RuntimeError("no run is open — call `run_start` first")
|
|
206
|
+
pkg = self.cfg.app.package
|
|
207
|
+
serial = run.device.get("serial") or (session.current_serial or "")
|
|
208
|
+
if serial:
|
|
209
|
+
run.write_logcat(serial, pkg)
|
|
210
|
+
timeline = run.write_timeline()
|
|
211
|
+
report = run.write_report()
|
|
212
|
+
run.finished = True
|
|
213
|
+
self.current = None
|
|
214
|
+
failures = [e.tool for e in run.failures]
|
|
215
|
+
log("run", f"finished {run.id} ({len(run.events)} steps, {len(failures)} failed)")
|
|
216
|
+
return {
|
|
217
|
+
"run_id": run.id,
|
|
218
|
+
"dir": str(run.dir),
|
|
219
|
+
"steps": len(run.events),
|
|
220
|
+
"failed": failures,
|
|
221
|
+
"passed": not failures,
|
|
222
|
+
"report": str(report),
|
|
223
|
+
"timeline": str(timeline),
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# ── artifacts ────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
def artifact_dir(self, kind: str = "failures") -> Path:
|
|
229
|
+
if self.current is not None and not self.current.finished:
|
|
230
|
+
return self.current.dir
|
|
231
|
+
path = self.root / kind / time.strftime("%Y%m%d-%H%M%S")
|
|
232
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
return path
|
|
234
|
+
|
|
235
|
+
def capture(self, session: Session, tag: str) -> dict[str, str]:
|
|
236
|
+
"""Screenshot + hierarchy for post-hoc triage. Never raises."""
|
|
237
|
+
out = self.artifact_dir()
|
|
238
|
+
stamp = f"{tag}-{int(time.time() * 1000) % 100000}"
|
|
239
|
+
captured: dict[str, str] = {}
|
|
240
|
+
png = out / f"{stamp}.png"
|
|
241
|
+
try:
|
|
242
|
+
session.driver.screenshot(png)
|
|
243
|
+
captured["screenshot"] = str(png)
|
|
244
|
+
except Exception as e:
|
|
245
|
+
log("run", f"screenshot capture failed for {tag}: {e}")
|
|
246
|
+
xml = out / f"{stamp}.xml"
|
|
247
|
+
try:
|
|
248
|
+
xml.write_text(session.driver.dump_hierarchy(), encoding="utf-8")
|
|
249
|
+
captured["hierarchy"] = str(xml)
|
|
250
|
+
except Exception as e:
|
|
251
|
+
log("run", f"hierarchy capture failed for {tag}: {e}")
|
|
252
|
+
if not captured:
|
|
253
|
+
# An unreachable device fails both captures; don't leave the empty
|
|
254
|
+
# directory behind to look like evidence that exists.
|
|
255
|
+
for path in (png, xml):
|
|
256
|
+
path.unlink(missing_ok=True)
|
|
257
|
+
if out != getattr(self.current, "dir", None) and not any(out.iterdir()):
|
|
258
|
+
out.rmdir()
|
|
259
|
+
return captured
|
|
260
|
+
|
|
261
|
+
def record_event(
|
|
262
|
+
self, tool: str, status: str, duration_s: float, detail: dict[str, Any] | None = None
|
|
263
|
+
) -> None:
|
|
264
|
+
if self.current is not None and not self.current.finished:
|
|
265
|
+
self.current.event(tool, status, duration_s, detail)
|
|
266
|
+
|
|
267
|
+
def list_runs(self, limit: int = 20) -> list[dict[str, Any]]:
|
|
268
|
+
if not self.root.is_dir():
|
|
269
|
+
return []
|
|
270
|
+
out: list[dict[str, Any]] = []
|
|
271
|
+
for path in sorted(self.root.iterdir(), reverse=True):
|
|
272
|
+
timeline = path / "timeline.json"
|
|
273
|
+
if not timeline.is_file():
|
|
274
|
+
continue
|
|
275
|
+
try:
|
|
276
|
+
data = json.loads(timeline.read_text(encoding="utf-8"))
|
|
277
|
+
except (OSError, json.JSONDecodeError):
|
|
278
|
+
continue
|
|
279
|
+
failed = [e["tool"] for e in data.get("events", []) if e.get("status") != "ok"]
|
|
280
|
+
out.append(
|
|
281
|
+
{
|
|
282
|
+
"run_id": data.get("id", path.name),
|
|
283
|
+
"name": data.get("name", ""),
|
|
284
|
+
"duration_s": data.get("duration_s"),
|
|
285
|
+
"steps": len(data.get("events", [])),
|
|
286
|
+
"failed": failed,
|
|
287
|
+
"dir": str(path),
|
|
288
|
+
}
|
|
289
|
+
)
|
|
290
|
+
if len(out) >= limit:
|
|
291
|
+
break
|
|
292
|
+
return out
|
android_driver/scan.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Selector scanning — what names actually exist in the app under test.
|
|
2
|
+
|
|
3
|
+
A recipe written against `desc: login_buton` fails at step four with "element not
|
|
4
|
+
found", and the agent then spends three tool calls proving the typo. Scanning the
|
|
5
|
+
project's own sources for the literals it declares turns that into a load-time
|
|
6
|
+
warning, and gives an agent a list of real names to write recipes against in the
|
|
7
|
+
first place.
|
|
8
|
+
|
|
9
|
+
The default patterns cover the two things Android apps actually label elements
|
|
10
|
+
with — Compose `testTag`/`contentDescription` and View-system `android:id` — plus
|
|
11
|
+
string resources, since visible copy is what `text:` selectors match. Projects
|
|
12
|
+
with their own convention add regexes under `selectors.patterns`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .config import Config
|
|
22
|
+
from .log import log
|
|
23
|
+
|
|
24
|
+
# Default globs, tried when the config does not name any.
|
|
25
|
+
DEFAULT_SOURCES = (
|
|
26
|
+
"**/src/**/*.kt",
|
|
27
|
+
"**/src/**/*.java",
|
|
28
|
+
"**/src/main/res/layout*/*.xml",
|
|
29
|
+
"**/src/main/res/values/strings.xml",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Directories never worth walking — build outputs dwarf the sources.
|
|
33
|
+
SKIP_DIRS = {"build", ".git", ".gradle", ".idea", "node_modules", "venv", ".venv", "__pycache__"}
|
|
34
|
+
|
|
35
|
+
# kind → regex with one capturing group holding the literal.
|
|
36
|
+
PATTERNS: dict[str, re.Pattern[str]] = {
|
|
37
|
+
# testTag("x"), testSemanticsTag("x"), fooTestTag = "x"
|
|
38
|
+
"tag": re.compile(r'[A-Za-z_]*[Tt]est(?:Semantics)?[Tt]ag\b\s*[=(]\s*"([^"\n]+)"'),
|
|
39
|
+
# contentDescription = "x" / contentDescription("x")
|
|
40
|
+
"desc": re.compile(r'contentDescription\s*[=(]\s*"([^"\n]+)"'),
|
|
41
|
+
# android:id="@+id/x" and R.id.x
|
|
42
|
+
"id": re.compile(r'android:id\s*=\s*"@\+?id/([\w.]+)"'),
|
|
43
|
+
"id_ref": re.compile(r"\bR\.id\.(\w+)"),
|
|
44
|
+
# <string name="x">visible copy</string>
|
|
45
|
+
"text": re.compile(r'<string\s+name="[^"]+"\s*>([^<]{1,80})</string>'),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Literals that resolve at runtime (`testTag = "demo_${item.name}_button"`) can
|
|
49
|
+
# never match a live element as written, so they are reported separately rather
|
|
50
|
+
# than presented as names a recipe can use.
|
|
51
|
+
TEMPLATE_RE = re.compile(r"\$\{|\$[A-Za-z_]|%[sd]|\{\d*\}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Selectors:
|
|
56
|
+
by_kind: dict[str, set[str]]
|
|
57
|
+
templates: set[str]
|
|
58
|
+
files_scanned: int
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def all(self) -> set[str]:
|
|
62
|
+
return {value for values in self.by_kind.values() for value in values}
|
|
63
|
+
|
|
64
|
+
def to_dict(self, limit: int | None = None) -> dict[str, list[str]]:
|
|
65
|
+
out = {kind: sorted(values) for kind, values in sorted(self.by_kind.items()) if values}
|
|
66
|
+
if limit:
|
|
67
|
+
out = {kind: values[:limit] for kind, values in out.items()}
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _iter_files(root: Path, globs: list[str]) -> list[Path]:
|
|
72
|
+
seen: dict[Path, None] = {}
|
|
73
|
+
for pattern in globs:
|
|
74
|
+
for path in root.glob(pattern):
|
|
75
|
+
if not path.is_file():
|
|
76
|
+
continue
|
|
77
|
+
if any(part in SKIP_DIRS for part in path.relative_to(root).parts):
|
|
78
|
+
continue
|
|
79
|
+
seen[path] = None
|
|
80
|
+
return list(seen)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def scan(cfg: Config) -> Selectors:
|
|
84
|
+
"""Collect every selector literal the project declares."""
|
|
85
|
+
spec = cfg.selectors or {}
|
|
86
|
+
globs = list(spec.get("sources") or DEFAULT_SOURCES)
|
|
87
|
+
extra = spec.get("patterns") or []
|
|
88
|
+
|
|
89
|
+
patterns = dict(PATTERNS)
|
|
90
|
+
for i, raw in enumerate(extra):
|
|
91
|
+
try:
|
|
92
|
+
patterns[f"custom{i + 1}"] = re.compile(raw)
|
|
93
|
+
except re.error as e:
|
|
94
|
+
log("scan", f"ignoring selectors.patterns[{i}] {raw!r}: {e}")
|
|
95
|
+
|
|
96
|
+
by_kind: dict[str, set[str]] = {kind: set() for kind in patterns}
|
|
97
|
+
templates: set[str] = set()
|
|
98
|
+
files = _iter_files(cfg.project_root, globs)
|
|
99
|
+
|
|
100
|
+
for path in files:
|
|
101
|
+
try:
|
|
102
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
103
|
+
except OSError as e:
|
|
104
|
+
log("scan", f"could not read {path}: {e}")
|
|
105
|
+
continue
|
|
106
|
+
for kind, rx in patterns.items():
|
|
107
|
+
for match in rx.finditer(text):
|
|
108
|
+
literal = (match.group(1) if rx.groups else match.group(0)).strip()
|
|
109
|
+
if not literal:
|
|
110
|
+
continue
|
|
111
|
+
if TEMPLATE_RE.search(literal):
|
|
112
|
+
templates.add(literal)
|
|
113
|
+
else:
|
|
114
|
+
by_kind[kind].add(literal)
|
|
115
|
+
|
|
116
|
+
# id and id_ref are the same namespace seen from XML and from Kotlin.
|
|
117
|
+
by_kind["id"] |= by_kind.pop("id_ref", set())
|
|
118
|
+
log("scan", f"scanned {len(files)} file(s); found {sum(len(v) for v in by_kind.values())} selectors")
|
|
119
|
+
return Selectors(by_kind=by_kind, templates=templates, files_scanned=len(files))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def check_recipes(recipes: dict, known: Selectors) -> list[str]:
|
|
123
|
+
"""Warn about recipe selectors the project's sources do not declare.
|
|
124
|
+
|
|
125
|
+
Only `desc` and `id` are checked. `text` selectors match visible copy that may
|
|
126
|
+
come from a translation, a server response or a formatted string, so absence
|
|
127
|
+
from the scanned set means nothing — flagging it would produce noise an
|
|
128
|
+
operator learns to ignore, which is worse than not checking.
|
|
129
|
+
"""
|
|
130
|
+
if not known.all:
|
|
131
|
+
return []
|
|
132
|
+
warnings: list[str] = []
|
|
133
|
+
tags = known.by_kind.get("tag", set()) | known.by_kind.get("desc", set())
|
|
134
|
+
ids = known.by_kind.get("id", set())
|
|
135
|
+
for name, recipe in recipes.items():
|
|
136
|
+
for index, step in enumerate(recipe.steps, start=1):
|
|
137
|
+
for key, pool, label in (("desc", tags, "testTag/contentDescription"), ("id", ids, "id")):
|
|
138
|
+
value = step.args.get(key) if isinstance(step.args, dict) else None
|
|
139
|
+
if not isinstance(value, str) or "{{" in value or not pool:
|
|
140
|
+
continue
|
|
141
|
+
if value not in pool:
|
|
142
|
+
near = _closest(value, pool)
|
|
143
|
+
hint = f" Did you mean {near!r}?" if near else ""
|
|
144
|
+
warnings.append(
|
|
145
|
+
f"{name} step {index} (`{step.verb}`) uses {key}={value!r}, "
|
|
146
|
+
f"which is not among the project's {label} literals.{hint}"
|
|
147
|
+
)
|
|
148
|
+
return warnings
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _closest(value: str, pool: set[str]) -> str | None:
|
|
152
|
+
import difflib
|
|
153
|
+
|
|
154
|
+
matches = difflib.get_close_matches(value, pool, n=1, cutoff=0.75)
|
|
155
|
+
return matches[0] if matches else None
|