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.
@@ -0,0 +1,547 @@
1
+ """YAML flows — the project's own test verbs, declared in config.
2
+
3
+ A recipe is a named sequence of steps a project runs over and over: sign in,
4
+ create an order, join a call. Written once in `.android-driver.yaml`, each one is
5
+ registered as a real MCP tool with typed parameters, so an agent sees
6
+ `login(email, password)` in its tool list rather than having to rediscover a
7
+ six-step flow from a screen dump every session.
8
+
9
+ recipes:
10
+ login:
11
+ description: Sign in and land on the home screen
12
+ params:
13
+ email: {required: true}
14
+ password: {required: true, secret: true}
15
+ steps:
16
+ - launch:
17
+ - type: {id: email_field, text: "{{email}}"}
18
+ - type: {id: password_field, text: "{{password}}"}
19
+ - tap: "Sign in"
20
+ - expect_visible: {text: Welcome, timeout_s: 20}
21
+
22
+ Steps run through the same `actions` functions the hand-driven tools use, so a
23
+ recipe cannot drift away from what an agent does interactively.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import inspect
29
+ import re
30
+ import time
31
+ from collections.abc import Callable
32
+ from dataclasses import dataclass, field
33
+ from typing import Any
34
+
35
+ from . import actions, adb, emulator, expect
36
+ from .config import Config
37
+ from .log import log
38
+ from .run import Runs
39
+ from .session import Session
40
+
41
+ MAX_RECIPE_DEPTH = 5
42
+ NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
43
+ PLACEHOLDER_RE = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}")
44
+ WHOLE_PLACEHOLDER_RE = re.compile(r"^\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}$")
45
+
46
+ PARAM_TYPES: dict[str, type] = {"str": str, "int": int, "float": float, "bool": bool}
47
+
48
+ # Keys on a step mapping that configure the step rather than the verb.
49
+ STEP_META = {"retry", "optional", "settle_s", "label"}
50
+
51
+ # Verbs whose single obvious argument can be written as a bare scalar.
52
+ SCALAR_ARG = {
53
+ "sleep": "seconds",
54
+ "press": "key",
55
+ "shell": "cmd",
56
+ "screenshot": "name",
57
+ "launch": "pkg",
58
+ "force_stop": "pkg",
59
+ "clear_data": "pkg",
60
+ "install": "apk_path",
61
+ "snapshot_save": "name",
62
+ "snapshot_load": "name",
63
+ "expect_log": "pattern",
64
+ "run": "recipe",
65
+ "tap": "text",
66
+ "long_press": "text",
67
+ "scroll_to": "text",
68
+ "expect_visible": "text",
69
+ "expect_gone": "text",
70
+ }
71
+
72
+
73
+ class RecipeError(RuntimeError):
74
+ """A recipe is malformed. Raised at load time, never mid-run."""
75
+
76
+
77
+ class StepFailed(RuntimeError):
78
+ """A step failed at runtime. Carries the structured detail for the report."""
79
+
80
+ def __init__(self, message: str, detail: dict[str, Any] | None = None) -> None:
81
+ super().__init__(message)
82
+ self.detail = detail or {}
83
+
84
+
85
+ # ── model ─────────────────────────────────────────────────────────────────────
86
+
87
+
88
+ @dataclass
89
+ class Param:
90
+ name: str
91
+ type: str = "str"
92
+ required: bool = False
93
+ default: Any = None
94
+ description: str = ""
95
+ secret: bool = False
96
+
97
+ @property
98
+ def py_type(self) -> type:
99
+ return PARAM_TYPES[self.type]
100
+
101
+
102
+ @dataclass
103
+ class Step:
104
+ verb: str
105
+ args: dict[str, Any] = field(default_factory=dict)
106
+ retry: int = 0
107
+ optional: bool = False
108
+ settle_s: float = 0.0
109
+ label: str = ""
110
+
111
+ @property
112
+ def name(self) -> str:
113
+ return self.label or self.verb
114
+
115
+
116
+ @dataclass
117
+ class Recipe:
118
+ name: str
119
+ description: str
120
+ params: list[Param]
121
+ steps: list[Step]
122
+ on_failure: str = "stop"
123
+
124
+ @property
125
+ def param_map(self) -> dict[str, Param]:
126
+ return {p.name: p for p in self.params}
127
+
128
+
129
+ def _parse_params(name: str, raw: Any) -> list[Param]:
130
+ if raw is None:
131
+ return []
132
+ if isinstance(raw, list): # shorthand: a list of required string params
133
+ return [Param(name=str(p), required=True) for p in raw]
134
+ if not isinstance(raw, dict):
135
+ raise RecipeError(f"recipe {name!r}: `params` must be a mapping or a list, got {type(raw).__name__}")
136
+
137
+ params: list[Param] = []
138
+ for key, spec in raw.items():
139
+ if not NAME_RE.match(str(key)):
140
+ raise RecipeError(f"recipe {name!r}: {key!r} is not a valid parameter name (a-z, 0-9, _)")
141
+ spec = spec or {}
142
+ if not isinstance(spec, dict):
143
+ spec = {"default": spec}
144
+ unknown = set(spec) - {"type", "required", "default", "description", "secret"}
145
+ if unknown:
146
+ raise RecipeError(f"recipe {name!r}: parameter {key!r} has unknown key(s) {sorted(unknown)}")
147
+ ptype = str(spec.get("type", "str"))
148
+ if ptype not in PARAM_TYPES:
149
+ raise RecipeError(
150
+ f"recipe {name!r}: parameter {key!r} has type {ptype!r}; use one of {sorted(PARAM_TYPES)}"
151
+ )
152
+ params.append(
153
+ Param(
154
+ name=str(key),
155
+ type=ptype,
156
+ required=bool(spec.get("required", "default" not in spec)),
157
+ default=spec.get("default"),
158
+ description=str(spec.get("description", "")),
159
+ secret=bool(spec.get("secret", False)),
160
+ )
161
+ )
162
+ return params
163
+
164
+
165
+ def _parse_step(recipe: str, index: int, raw: Any) -> Step:
166
+ where = f"recipe {recipe!r} step {index + 1}"
167
+ if isinstance(raw, str): # bare verb, no arguments: `- launch`
168
+ raw = {raw: None}
169
+ if not isinstance(raw, dict):
170
+ raise RecipeError(f"{where}: each step must be a mapping like `- tap: {{text: OK}}`")
171
+
172
+ meta = {k: v for k, v in raw.items() if k in STEP_META}
173
+ verbs = [k for k in raw if k not in STEP_META]
174
+ if len(verbs) != 1:
175
+ raise RecipeError(
176
+ f"{where}: expected exactly one verb, found {verbs or 'none'}. "
177
+ "Write each action as its own list item."
178
+ )
179
+ verb = verbs[0]
180
+ if verb not in VERBS:
181
+ raise RecipeError(f"{where}: unknown step {verb!r}. Known steps: {sorted(VERBS)}")
182
+
183
+ args = raw[verb]
184
+ if args is None:
185
+ args = {}
186
+ elif not isinstance(args, dict):
187
+ key = SCALAR_ARG.get(verb)
188
+ if key is None:
189
+ raise RecipeError(f"{where}: `{verb}` needs a mapping of arguments, got {args!r}")
190
+ args = {key: args}
191
+ else:
192
+ # `retry` reads naturally beside `timeout_s`, so accept step options written
193
+ # inside the verb's own mapping as well as beside it. Silently passing them
194
+ # through as verb arguments instead fails at runtime with a confusing
195
+ # signature error, and — worse — quietly drops the retry the author asked
196
+ # for. No verb takes an argument by any of these names.
197
+ args = dict(args)
198
+ for key in sorted(STEP_META & set(args)):
199
+ meta.setdefault(key, args.pop(key))
200
+
201
+ return Step(
202
+ verb=verb,
203
+ args=args,
204
+ retry=int(meta.get("retry", 0)),
205
+ optional=bool(meta.get("optional", False)),
206
+ settle_s=float(meta.get("settle_s", 0.0)),
207
+ label=str(meta.get("label", "")),
208
+ )
209
+
210
+
211
+ def parse(name: str, raw: Any) -> Recipe:
212
+ """Turn one entry of the config's `recipes:` mapping into a Recipe."""
213
+ if not NAME_RE.match(name):
214
+ raise RecipeError(f"recipe name {name!r} must match {NAME_RE.pattern} to be usable as a tool name")
215
+ if isinstance(raw, list): # shorthand: just steps
216
+ raw = {"steps": raw}
217
+ if not isinstance(raw, dict):
218
+ raise RecipeError(f"recipe {name!r} must be a mapping, got {type(raw).__name__}")
219
+
220
+ unknown = set(raw) - {"description", "params", "steps", "on_failure"}
221
+ if unknown:
222
+ raise RecipeError(f"recipe {name!r}: unknown key(s) {sorted(unknown)}")
223
+
224
+ steps_raw = raw.get("steps")
225
+ if not steps_raw:
226
+ raise RecipeError(f"recipe {name!r} has no steps")
227
+ if not isinstance(steps_raw, list):
228
+ raise RecipeError(f"recipe {name!r}: `steps` must be a list")
229
+
230
+ on_failure = str(raw.get("on_failure", "stop"))
231
+ if on_failure not in {"stop", "continue"}:
232
+ raise RecipeError(f"recipe {name!r}: on_failure must be 'stop' or 'continue', got {on_failure!r}")
233
+
234
+ return Recipe(
235
+ name=name,
236
+ description=str(raw.get("description", "") or f"Run the {name} flow"),
237
+ params=_parse_params(name, raw.get("params")),
238
+ steps=[_parse_step(name, i, s) for i, s in enumerate(steps_raw)],
239
+ on_failure=on_failure,
240
+ )
241
+
242
+
243
+ def load_all(cfg: Config) -> dict[str, Recipe]:
244
+ """Parse every recipe in the config. One bad recipe does not hide the others."""
245
+ out: dict[str, Recipe] = {}
246
+ for name, raw in (cfg.recipes or {}).items():
247
+ try:
248
+ out[str(name)] = parse(str(name), raw)
249
+ except RecipeError as e:
250
+ log("recipes", f"SKIPPED: {e}")
251
+ return out
252
+
253
+
254
+ # ── interpolation ─────────────────────────────────────────────────────────────
255
+
256
+
257
+ def interpolate(value: Any, params: dict[str, Any]) -> Any:
258
+ """Substitute `{{param}}` throughout a step's arguments.
259
+
260
+ A value that is *entirely* one placeholder keeps the parameter's own type, so
261
+ `timeout_s: "{{wait}}"` with `wait=30` stays the integer 30 rather than "30".
262
+ """
263
+ if isinstance(value, str):
264
+ whole = WHOLE_PLACEHOLDER_RE.match(value.strip())
265
+ if whole:
266
+ return _lookup(whole.group(1), params)
267
+ return PLACEHOLDER_RE.sub(lambda m: str(_lookup(m.group(1), params)), value)
268
+ if isinstance(value, dict):
269
+ return {k: interpolate(v, params) for k, v in value.items()}
270
+ if isinstance(value, list):
271
+ return [interpolate(v, params) for v in value]
272
+ return value
273
+
274
+
275
+ def _lookup(name: str, params: dict[str, Any]) -> Any:
276
+ if name not in params:
277
+ raise StepFailed(
278
+ f"{{{{{name}}}}} is not a parameter of this recipe. Available: {sorted(params) or '(none)'}"
279
+ )
280
+ return params[name]
281
+
282
+
283
+ # ── execution ─────────────────────────────────────────────────────────────────
284
+
285
+
286
+ class Context:
287
+ """Everything a step needs to touch the device."""
288
+
289
+ def __init__(self, session: Session, cfg: Config, runs: Runs) -> None:
290
+ self.session = session
291
+ self.cfg = cfg
292
+ self.runs = runs
293
+
294
+
295
+ def _expect_result(result: dict[str, Any]) -> dict[str, Any]:
296
+ """Turn a failed assertion into a step failure; pass a successful one through."""
297
+ if result.get("ok") is False:
298
+ raise StepFailed(str(result.get("error", "assertion failed")), result)
299
+ return result
300
+
301
+
302
+ def _step_sleep(ctx: Context, seconds: float = 1.0) -> dict[str, Any]:
303
+ time.sleep(float(seconds))
304
+ return {"slept_s": float(seconds)}
305
+
306
+
307
+ def _step_screenshot(ctx: Context, name: str | None = None) -> dict[str, Any]:
308
+ stem = name or f"step-{int(time.time() * 1000) % 100000}"
309
+ path = ctx.runs.artifact_dir("screenshots") / f"{stem}.png"
310
+ actions.screenshot(ctx.session, path)
311
+ return {"screenshot": str(path)}
312
+
313
+
314
+ # verb → (callable taking (ctx, **args))
315
+ VERBS: dict[str, Callable[..., dict[str, Any]]] = {
316
+ # UI
317
+ "tap": lambda ctx, **kw: actions.tap(ctx.session, **_rename_id(kw)),
318
+ "tap_xy": lambda ctx, **kw: actions.tap_xy(ctx.session, **kw),
319
+ "long_press": lambda ctx, **kw: actions.long_press(ctx.session, **_rename_id(kw)),
320
+ "type": lambda ctx, **kw: actions.type_text(ctx.session, **_rename_id(kw)),
321
+ "swipe": lambda ctx, **kw: actions.swipe(ctx.session, **kw),
322
+ "scroll_to": lambda ctx, **kw: actions.scroll_to(ctx.session, **_rename_id(kw)),
323
+ "press": lambda ctx, **kw: actions.press_key(ctx.session, **kw),
324
+ "screenshot": _step_screenshot,
325
+ # app lifecycle
326
+ "build": lambda ctx, **kw: actions.build_app(ctx.cfg, **kw),
327
+ "install": lambda ctx, **kw: actions.install_app(ctx.session, ctx.cfg, **kw),
328
+ "uninstall": lambda ctx, **kw: actions.uninstall_app(ctx.session, ctx.cfg, **kw),
329
+ "launch": lambda ctx, **kw: actions.launch_app(ctx.session, ctx.cfg, **kw),
330
+ "force_stop": lambda ctx, **kw: actions.force_stop(ctx.session, ctx.cfg, **kw),
331
+ "clear_data": lambda ctx, **kw: actions.clear_app_data(ctx.session, ctx.cfg, **kw),
332
+ # emulator
333
+ "snapshot_save": lambda ctx, **kw: emulator.snapshot_save(ctx.session.serial, **kw),
334
+ "snapshot_load": lambda ctx, **kw: _after_snapshot(ctx, emulator.snapshot_load(ctx.session.serial, **kw)),
335
+ # assertions
336
+ "expect_visible": lambda ctx, **kw: _expect_result(expect.visible(ctx.session, **_rename_id(kw))),
337
+ "expect_gone": lambda ctx, **kw: _expect_result(expect.gone(ctx.session, **_rename_id(kw))),
338
+ "expect_log": lambda ctx, **kw: _expect_result(expect.log_matches(ctx.session, ctx.cfg, **kw)),
339
+ "expect_no_crash": lambda ctx, **kw: _expect_result(expect.no_crash(ctx.session, ctx.cfg, **kw)),
340
+ # misc
341
+ "sleep": _step_sleep,
342
+ "shell": lambda ctx, **kw: actions.shell(ctx.session, **kw),
343
+ "logcat_clear": lambda ctx: (adb.logcat_clear(ctx.session.serial), {})[1],
344
+ "run": lambda ctx, **kw: {}, # replaced by Runner; declared here so parsing accepts it
345
+ }
346
+
347
+
348
+ def _rename_id(kw: dict[str, Any]) -> dict[str, Any]:
349
+ """`id:` reads better in YAML than `rid:`, which is what the action layer takes."""
350
+ if "id" in kw:
351
+ kw = dict(kw)
352
+ kw["rid"] = kw.pop("id")
353
+ return kw
354
+
355
+
356
+ def _after_snapshot(ctx: Context, result: dict[str, Any]) -> dict[str, Any]:
357
+ ctx.session.invalidate()
358
+ return result
359
+
360
+
361
+ class Runner:
362
+ def __init__(self, ctx: Context, registry: dict[str, Recipe]) -> None:
363
+ self.ctx = ctx
364
+ self.registry = registry
365
+
366
+ def run(self, recipe: Recipe, params: dict[str, Any], depth: int = 0) -> dict[str, Any]:
367
+ if depth > MAX_RECIPE_DEPTH:
368
+ raise StepFailed(f"recipe nesting deeper than {MAX_RECIPE_DEPTH}; is {recipe.name!r} recursive?")
369
+
370
+ bound = self._bind(recipe, params)
371
+ redacted = {
372
+ p.name: ("***" if p.secret else bound[p.name]) for p in recipe.params if p.name in bound
373
+ }
374
+ log("recipes", f"running {recipe.name}({redacted})")
375
+
376
+ started = time.monotonic()
377
+ records: list[dict[str, Any]] = []
378
+ failure: dict[str, Any] | None = None
379
+
380
+ for index, step in enumerate(recipe.steps):
381
+ record = self._run_step(recipe, index, step, bound, depth)
382
+ records.append(record)
383
+ if record["status"] == "failed":
384
+ failure = record
385
+ if recipe.on_failure == "stop":
386
+ break
387
+
388
+ ok = failure is None
389
+ result: dict[str, Any] = {
390
+ "ok": ok,
391
+ "recipe": recipe.name,
392
+ "params": redacted,
393
+ "steps": records,
394
+ "duration_s": round(time.monotonic() - started, 2),
395
+ }
396
+ if failure is not None:
397
+ result["error"] = f"step {failure['index']} (`{failure['step']}`) failed: {failure['error']}"
398
+ result["failed_step"] = failure
399
+ return result
400
+
401
+ # ── internals ────────────────────────────────────────────────────────────
402
+
403
+ def _bind(self, recipe: Recipe, params: dict[str, Any]) -> dict[str, Any]:
404
+ known = recipe.param_map
405
+ unknown = set(params) - set(known)
406
+ if unknown:
407
+ raise StepFailed(
408
+ f"recipe {recipe.name!r} got unknown parameter(s) {sorted(unknown)}; "
409
+ f"it takes {sorted(known) or '(none)'}"
410
+ )
411
+ bound: dict[str, Any] = {}
412
+ for param in recipe.params:
413
+ if param.name in params and params[param.name] is not None:
414
+ bound[param.name] = params[param.name]
415
+ elif param.required:
416
+ raise StepFailed(f"recipe {recipe.name!r} requires the {param.name!r} parameter")
417
+ else:
418
+ bound[param.name] = param.default
419
+ return bound
420
+
421
+ def _run_step(
422
+ self, recipe: Recipe, index: int, step: Step, params: dict[str, Any], depth: int
423
+ ) -> dict[str, Any]:
424
+ label = f"{recipe.name}.{step.name}"
425
+ started = time.monotonic()
426
+ attempts = step.retry + 1
427
+ last_error: Exception | None = None
428
+ detail: dict[str, Any] = {}
429
+
430
+ for attempt in range(attempts):
431
+ try:
432
+ args = interpolate(step.args, params)
433
+ payload = self._dispatch(step.verb, args, depth) or {}
434
+ if step.settle_s:
435
+ time.sleep(step.settle_s)
436
+ duration = round(time.monotonic() - started, 3)
437
+ self.ctx.runs.record_event(label, "ok", duration, summarize(payload))
438
+ return {
439
+ "index": index + 1,
440
+ "step": step.name,
441
+ "status": "ok",
442
+ "duration_s": duration,
443
+ **summarize(payload),
444
+ }
445
+ except Exception as e:
446
+ last_error = e
447
+ detail = getattr(e, "detail", {}) or {}
448
+ if attempt < attempts - 1:
449
+ log("recipes", f"{label} failed ({e}); retry {attempt + 1}/{step.retry}")
450
+ time.sleep(0.5 * (attempt + 1))
451
+ # Re-read the screen before trying again. A retry against the
452
+ # same cached snapshot can only fail the same way, and the
453
+ # usual reason a step needs retrying is that the UI had not
454
+ # finished settling when we looked.
455
+ self.ctx.session.invalidate()
456
+
457
+ duration = round(time.monotonic() - started, 3)
458
+ message = f"{type(last_error).__name__}: {last_error}"
459
+ artifacts = self.ctx.runs.capture(self.ctx.session, f"{recipe.name}-{index + 1}-{step.verb}")
460
+ status = "skipped" if step.optional else "failed"
461
+ record = {
462
+ "index": index + 1,
463
+ "step": step.name,
464
+ "status": status,
465
+ "duration_s": duration,
466
+ "error": message,
467
+ **artifacts,
468
+ }
469
+ if detail.get("screen"):
470
+ record["screen"] = detail["screen"]
471
+ self.ctx.runs.record_event(label, status, duration, {"error": message, **artifacts})
472
+ return record
473
+
474
+ def _dispatch(self, verb: str, args: dict[str, Any], depth: int) -> dict[str, Any]:
475
+ if verb == "run":
476
+ nested = args.get("recipe")
477
+ if nested not in self.registry:
478
+ raise StepFailed(f"no recipe named {nested!r}. Known: {sorted(self.registry)}")
479
+ child_params = {k: v for k, v in args.items() if k != "recipe"}
480
+ result = self.run(self.registry[nested], child_params, depth + 1)
481
+ if not result["ok"]:
482
+ raise StepFailed(result.get("error", f"nested recipe {nested!r} failed"), result)
483
+ return {"recipe": nested, "steps": len(result["steps"])}
484
+ try:
485
+ return VERBS[verb](self.ctx, **args)
486
+ except TypeError as e:
487
+ # A wrong argument name is a config mistake, so say so in those terms
488
+ # rather than leaking a Python signature error at the agent.
489
+ raise StepFailed(f"`{verb}` does not accept those arguments ({e})") from e
490
+
491
+
492
+ def summarize(payload: dict[str, Any], limit: int = 300) -> dict[str, Any]:
493
+ """Keep timeline entries readable — drop the bulky fields assertions return."""
494
+ out: dict[str, Any] = {}
495
+ for key, value in payload.items():
496
+ if key in {"screen", "excerpt", "tail", "matches", "steps"}:
497
+ continue
498
+ if isinstance(value, str) and len(value) > limit:
499
+ value = value[:limit] + "…"
500
+ out[key] = value
501
+ return out
502
+
503
+
504
+ # ── MCP tool generation ───────────────────────────────────────────────────────
505
+
506
+
507
+ def build_tool(recipe: Recipe, runner_factory: Callable[[], Runner]) -> Callable[..., dict[str, Any]]:
508
+ """Wrap a recipe in a function whose signature is its parameter list.
509
+
510
+ FastMCP derives a tool's JSON schema from `inspect.signature`, so a synthetic
511
+ signature is what makes `login(email, password)` show up as a typed tool
512
+ instead of an opaque `run_recipe(name, params)` call.
513
+ """
514
+ def tool(**kwargs: Any) -> dict[str, Any]:
515
+ try:
516
+ return runner_factory().run(recipe, kwargs)
517
+ except StepFailed as e:
518
+ return {"ok": False, "recipe": recipe.name, "error": str(e), **(e.detail or {})}
519
+ except Exception as e:
520
+ return {"ok": False, "recipe": recipe.name, "error": f"{type(e).__name__}: {e}"}
521
+
522
+ ordered = sorted(recipe.params, key=lambda p: not p.required)
523
+ parameters = [
524
+ inspect.Parameter(
525
+ p.name,
526
+ inspect.Parameter.KEYWORD_ONLY,
527
+ default=inspect.Parameter.empty if p.required else p.default,
528
+ annotation=p.py_type if p.required else (p.py_type | None),
529
+ )
530
+ for p in ordered
531
+ ]
532
+ tool.__signature__ = inspect.Signature(parameters, return_annotation=dict[str, Any]) # type: ignore[attr-defined]
533
+ tool.__name__ = recipe.name
534
+ tool.__doc__ = _docstring(recipe)
535
+ return tool
536
+
537
+
538
+ def _docstring(recipe: Recipe) -> str:
539
+ lines = [recipe.description.strip() or f"Run the {recipe.name} flow."]
540
+ if recipe.params:
541
+ lines.append("")
542
+ for p in recipe.params:
543
+ suffix = "" if p.required else f" (default: {p.default!r})"
544
+ lines.append(f" {p.name}: {p.description or p.type}{suffix}")
545
+ lines.append("")
546
+ lines.append(f"Steps: {' → '.join(s.name for s in recipe.steps)}")
547
+ return "\n".join(lines)
@@ -0,0 +1,112 @@
1
+ """Screen recording via `screenrecord`.
2
+
3
+ The device-side process is started detached and stopped with SIGINT rather than
4
+ being killed: `screenrecord` only writes the MP4 moov atom when it shuts down
5
+ cleanly, and a SIGKILLed recording leaves a file no player will open. That is
6
+ why this goes through a device-side PID instead of the simpler
7
+ `Popen(["adb", "shell", "screenrecord", ...])` — a local kill does not reliably
8
+ reach the remote process at all.
9
+
10
+ `screenrecord` caps a single clip at 3 minutes and does not capture on some
11
+ emulator GPU configurations; both show up as an empty or missing file, which
12
+ `stop()` reports rather than hiding.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import time
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from . import adb
22
+ from .log import log
23
+
24
+ REMOTE_DIR = "/sdcard"
25
+ MAX_TIME_LIMIT_S = 180
26
+
27
+
28
+ class RecordError(RuntimeError):
29
+ pass
30
+
31
+
32
+ class Recorder:
33
+ def __init__(self) -> None:
34
+ self.active: dict[str, Any] | None = None
35
+
36
+ def start(
37
+ self,
38
+ serial: str,
39
+ *,
40
+ name: str | None = None,
41
+ bit_rate_mbps: float = 4.0,
42
+ size: str | None = None,
43
+ time_limit_s: int = MAX_TIME_LIMIT_S,
44
+ ) -> dict[str, Any]:
45
+ if self.active is not None:
46
+ raise RecordError(
47
+ f"already recording to {self.active['remote']}; call `record_stop` first"
48
+ )
49
+ if time_limit_s > MAX_TIME_LIMIT_S:
50
+ log("record", f"screenrecord caps clips at {MAX_TIME_LIMIT_S}s; clamping {time_limit_s}s")
51
+ time_limit_s = MAX_TIME_LIMIT_S
52
+
53
+ stem = name or f"record-{int(time.time())}"
54
+ remote = f"{REMOTE_DIR}/android-driver-{stem}.mp4"
55
+ opts = f"--bit-rate {int(bit_rate_mbps * 1_000_000)} --time-limit {time_limit_s}"
56
+ if size:
57
+ opts += f" --size {size}"
58
+ # nohup + & keeps it alive after this adb shell exits; `echo $!` hands
59
+ # back the device-side PID we need in order to SIGINT it later.
60
+ result = adb.shell_result(
61
+ serial, f"nohup screenrecord {opts} {remote} >/dev/null 2>&1 & echo $!", timeout=30
62
+ )
63
+ pid = result["stdout"].strip().split()[-1] if result["stdout"].strip() else ""
64
+ if not pid.isdigit():
65
+ raise RecordError(
66
+ f"could not start screenrecord on {serial}: {result['stdout']} {result['stderr']}".strip()
67
+ )
68
+ self.active = {"serial": serial, "remote": remote, "pid": int(pid), "started": time.monotonic()}
69
+ log("record", f"recording {serial} → {remote} (pid {pid})")
70
+ return {"remote_path": remote, "pid": int(pid), "time_limit_s": time_limit_s}
71
+
72
+ def stop(self, dest: Path) -> dict[str, Any]:
73
+ if self.active is None:
74
+ raise RecordError("not recording — call `record_start` first")
75
+ state, self.active = self.active, None
76
+ serial, remote, pid = state["serial"], state["remote"], state["pid"]
77
+
78
+ adb.shell_result(serial, f"kill -2 {pid}", timeout=30)
79
+ self._await_finalized(serial, remote)
80
+
81
+ dest = Path(dest)
82
+ dest.parent.mkdir(parents=True, exist_ok=True)
83
+ pull = adb.run(serial, "pull", remote, str(dest), check=False, timeout=180)
84
+ adb.shell_result(serial, f"rm -f {remote}", timeout=30)
85
+
86
+ if not dest.is_file() or dest.stat().st_size == 0:
87
+ raise RecordError(
88
+ f"screenrecord produced no usable file (adb pull said: {pull.strip()}). "
89
+ "Some emulator GPU modes cannot capture; try `-gpu swiftshader_indirect`."
90
+ )
91
+ return {
92
+ "path": str(dest),
93
+ "bytes": dest.stat().st_size,
94
+ "seconds": round(time.monotonic() - state["started"], 1),
95
+ }
96
+
97
+ @staticmethod
98
+ def _await_finalized(serial: str, remote: str, timeout_s: float = 15.0) -> None:
99
+ """Wait for the file size to stop growing — the moov atom is written last."""
100
+ deadline = time.monotonic() + timeout_s
101
+ last = -1
102
+ while time.monotonic() < deadline:
103
+ time.sleep(0.5)
104
+ out = adb.shell_result(serial, f"stat -c %s {remote} 2>/dev/null || echo 0")["stdout"]
105
+ try:
106
+ size = int(out.strip().splitlines()[-1])
107
+ except (ValueError, IndexError):
108
+ size = 0
109
+ if size and size == last:
110
+ return
111
+ last = size
112
+ log("record", f"{remote} was still changing after {timeout_s}s; pulling anyway")