tapwright 1.2.0__tar.gz → 1.3.0__tar.gz

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 (95) hide show
  1. {tapwright-1.2.0/tapwright.egg-info → tapwright-1.3.0}/PKG-INFO +3 -1
  2. {tapwright-1.2.0 → tapwright-1.3.0}/README.md +2 -0
  3. {tapwright-1.2.0 → tapwright-1.3.0}/pyproject.toml +1 -1
  4. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/__init__.py +1 -1
  5. tapwright-1.3.0/tapwright/aio.py +56 -0
  6. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/batch.py +16 -6
  7. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/cli.py +30 -2
  8. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/daemon.py +657 -29
  9. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/descriptions.py +32 -0
  10. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/device.py +422 -2
  11. tapwright-1.3.0/tapwright/diagnostics.py +421 -0
  12. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/mcp_server.py +18 -0
  13. tapwright-1.3.0/tapwright/performance.py +193 -0
  14. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/skill/SKILL.md +3 -1
  15. {tapwright-1.2.0 → tapwright-1.3.0/tapwright.egg-info}/PKG-INFO +3 -1
  16. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright.egg-info/SOURCES.txt +8 -0
  17. tapwright-1.3.0/tests/test_aio.py +154 -0
  18. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_compare_screen.py +29 -0
  19. tapwright-1.3.0/tests/test_crash_reading.py +213 -0
  20. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_daemon.py +1 -1
  21. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_descriptions.py +1 -1
  22. tapwright-1.3.0/tests/test_diagnostics.py +1217 -0
  23. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_mcp_tools.py +6 -1
  24. tapwright-1.3.0/tests/test_meter.py +362 -0
  25. tapwright-1.3.0/tests/test_performance.py +407 -0
  26. {tapwright-1.2.0 → tapwright-1.3.0}/LICENSE +0 -0
  27. {tapwright-1.2.0 → tapwright-1.3.0}/setup.cfg +0 -0
  28. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/__main__.py +0 -0
  29. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/actionlog.py +0 -0
  30. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/airplay.py +0 -0
  31. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/audit.py +0 -0
  32. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/changes.py +0 -0
  33. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/client.py +0 -0
  34. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/compare.py +0 -0
  35. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/connect.py +0 -0
  36. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/control.py +0 -0
  37. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/errors.py +0 -0
  38. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/filmstrip.py +0 -0
  39. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/mirror.py +0 -0
  40. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/ocr.py +0 -0
  41. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/policy.py +0 -0
  42. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/repair.py +0 -0
  43. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/rtp.py +0 -0
  44. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/server.py +0 -0
  45. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/session.py +0 -0
  46. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/shot.py +0 -0
  47. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/signing.py +0 -0
  48. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/skill/agents/openai.yaml +0 -0
  49. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/skill/references/setup.md +0 -0
  50. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/skills.py +0 -0
  51. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/store.py +0 -0
  52. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/stream.py +0 -0
  53. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/tapline.py +0 -0
  54. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/ui.py +0 -0
  55. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/video.py +0 -0
  56. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/viewer.html +0 -0
  57. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright.egg-info/dependency_links.txt +0 -0
  58. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright.egg-info/entry_points.txt +0 -0
  59. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright.egg-info/requires.txt +0 -0
  60. {tapwright-1.2.0 → tapwright-1.3.0}/tapwright.egg-info/top_level.txt +0 -0
  61. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_actionlog.py +0 -0
  62. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_airplay.py +0 -0
  63. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_audit.py +0 -0
  64. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_batch.py +0 -0
  65. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_changes.py +0 -0
  66. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_cli.py +0 -0
  67. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_cli_replay.py +0 -0
  68. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_compare.py +0 -0
  69. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_connect.py +0 -0
  70. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_control.py +0 -0
  71. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_daemon_video.py +0 -0
  72. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_device.py +0 -0
  73. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_filmstrip.py +0 -0
  74. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_mcp.py +0 -0
  75. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_mirror.py +0 -0
  76. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_ocr.py +0 -0
  77. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_offline.py +0 -0
  78. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_policy.py +0 -0
  79. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_repair.py +0 -0
  80. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_rtp.py +0 -0
  81. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_serve.py +0 -0
  82. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_server.py +0 -0
  83. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_session.py +0 -0
  84. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_setup_resume.py +0 -0
  85. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_shot.py +0 -0
  86. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_skill.py +0 -0
  87. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_store.py +0 -0
  88. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_stream.py +0 -0
  89. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_stream_pictures.py +0 -0
  90. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_tapline_notice.py +0 -0
  91. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_targets.py +0 -0
  92. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_ui.py +0 -0
  93. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_ui_tools.py +0 -0
  94. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_video.py +0 -0
  95. {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_video_pictures.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tapwright
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Let LLM agents test apps on your real iPhone, from Windows, Linux or macOS. No Mac needed.
5
5
  Author: bo5amis
6
6
  License-Expression: MIT
@@ -177,7 +177,9 @@ repository too.
177
177
  | `viewer` | opens the live view in a browser on this computer, for you (the agent never gets the link: its key could approve a held-back touch) |
178
178
  | `run` | several steps in one call, each with an optional check (`"expect": {"text": "Saved"}`); stops at the first that fails and answers once |
179
179
  | `record`, `replay` | save what the agent does as a script, and run it again; `tapwright replay FILE --apps ID` runs it in CI with no agent (exit code, `--junit`) |
180
+ | `logs`, `crash` | the app's own log (level, grep, since) and its crash reports, read from the phone; a failed `run` step adds the log lines from that step, and a call the app crashed in ends with a note |
180
181
  | `audit` | an accessibility check of the screen, by Apple's own XCTest audit: low contrast, tap targets under 44x44 pt, missing labels, clipped text, text that doesn't scale, wrong traits, duplicate ids; each issue names the element and the fix |
182
+ | `performance` | memory, CPU and frame rate of the app's own process while it waits, around a tap, or from a cold start to the first settled screen (`launch`); `run` and scripts can hold it to limits with `expect.under` |
181
183
  | `compare_screen` | visual regression: the first call saves the screen as a reference picture, later calls answer `Matches` or `Differs` with where it changed and a picture with the changes in red; `threshold` and `ignore` (a clock, an ad) tune it, and the status bar is never compared |
182
184
  | `filmstrip` | several frames from the live stream in one numbered picture, for animations and loading states; `tap` first taps and films what it sets off |
183
185
 
@@ -140,7 +140,9 @@ repository too.
140
140
  | `viewer` | opens the live view in a browser on this computer, for you (the agent never gets the link: its key could approve a held-back touch) |
141
141
  | `run` | several steps in one call, each with an optional check (`"expect": {"text": "Saved"}`); stops at the first that fails and answers once |
142
142
  | `record`, `replay` | save what the agent does as a script, and run it again; `tapwright replay FILE --apps ID` runs it in CI with no agent (exit code, `--junit`) |
143
+ | `logs`, `crash` | the app's own log (level, grep, since) and its crash reports, read from the phone; a failed `run` step adds the log lines from that step, and a call the app crashed in ends with a note |
143
144
  | `audit` | an accessibility check of the screen, by Apple's own XCTest audit: low contrast, tap targets under 44x44 pt, missing labels, clipped text, text that doesn't scale, wrong traits, duplicate ids; each issue names the element and the fix |
145
+ | `performance` | memory, CPU and frame rate of the app's own process while it waits, around a tap, or from a cold start to the first settled screen (`launch`); `run` and scripts can hold it to limits with `expect.under` |
144
146
  | `compare_screen` | visual regression: the first call saves the screen as a reference picture, later calls answer `Matches` or `Differs` with where it changed and a picture with the changes in red; `threshold` and `ignore` (a clock, an ad) tune it, and the status bar is never compared |
145
147
  | `filmstrip` | several frames from the live stream in one numbered picture, for animations and loading states; `tap` first taps and films what it sets off |
146
148
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tapwright"
7
- version = "1.2.0"
7
+ version = "1.3.0"
8
8
  description = "Let LLM agents test apps on your real iPhone, from Windows, Linux or macOS. No Mac needed."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,2 +1,2 @@
1
1
  """tapwright: let LLM agents test apps on your real iPhone."""
2
- __version__ = "1.2.0"
2
+ __version__ = "1.3.0"
@@ -0,0 +1,56 @@
1
+ """Waiting that is safe to cancel, for daemon.py and device.py alike."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from typing import Awaitable, TypeVar
6
+
7
+ _T = TypeVar("_T")
8
+
9
+
10
+ def _retrieved(task: asyncio.Future) -> None:
11
+ """A finished child's error is read, so it is never logged as `never retrieved`."""
12
+ if not task.cancelled():
13
+ task.exception()
14
+
15
+
16
+ async def _join(inner: asyncio.Future) -> None:
17
+ """Cancel the child and wait until it has ended (its own cleanup included), whatever it ended with. A cancel of
18
+ the caller during the wait is kept, not acted on: the child is still waited for (it is not cancelled again, which
19
+ would cut its cleanup short), and the cancel is raised once it has ended."""
20
+ inner.cancel()
21
+ cancelled = False
22
+ while not inner.done():
23
+ try:
24
+ await asyncio.wait({inner})
25
+ except asyncio.CancelledError:
26
+ cancelled = True # (the child was cancelled once already: its cleanup is not cut short by this)
27
+ _retrieved(inner)
28
+ if cancelled:
29
+ raise asyncio.CancelledError
30
+
31
+
32
+ async def wait_for(awaitable: Awaitable[_T], seconds: float) -> _T:
33
+ """`asyncio.wait_for`, except that
34
+ - a cancel of the caller is never lost (before Python 3.12, wait_for gives its result and drops the cancel when
35
+ the two come in the same loop turn, and a loop that waits for something again then never ends: found in
36
+ `_watch_logs`, which `supervise` cancels), and
37
+ - the child is always cancelled and joined before this returns or raises, and its error is always retrieved
38
+ (cleanup of a DVT session that is still running must not outlive the caller)."""
39
+ inner = asyncio.ensure_future(awaitable)
40
+ try:
41
+ done, _ = await asyncio.wait({inner}, timeout=seconds)
42
+ except BaseException: # the caller was cancelled (or the loop is closing)
43
+ await _join_quietly(inner)
44
+ raise
45
+ if not done:
46
+ await _join(inner)
47
+ raise asyncio.TimeoutError
48
+ return inner.result()
49
+
50
+
51
+ async def _join_quietly(inner: asyncio.Future) -> None:
52
+ """_join for a caller that is being cancelled: the cancel is already on its way, and stays."""
53
+ try:
54
+ await _join(inner)
55
+ except asyncio.CancelledError:
56
+ pass
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  from pathlib import Path
7
7
 
8
- from . import compare, ui
8
+ from . import compare, performance, ui
9
9
  from . import shot as shotlib
10
10
  from .control import Refused
11
11
 
@@ -13,7 +13,7 @@ MAX_STEPS = 50
13
13
  BATCH_LIMIT = 120.0 # seconds a whole batch may take
14
14
  EXPECT_DEFAULT, EXPECT_MIN, EXPECT_MAX = 2.0, 0.5, 30.0
15
15
  STEP_TOOLS = ("tap", "long_press", "type_text", "scroll", "toggle", "back", "swipe", "open_url", "wait", "wait_for",
16
- "alert", "press_button", "rotate", "open_app", "close_app", "compare_screen")
16
+ "alert", "press_button", "rotate", "open_app", "close_app", "compare_screen", "performance")
17
17
  EXPECT_KINDS = ("text", "id", "label")
18
18
  FORMAT, VERSION = "agent-tools-script", 1
19
19
 
@@ -32,14 +32,14 @@ def check_steps(steps: object, *, script: bool = False) -> list[dict]:
32
32
  if step["tool"] not in STEP_TOOLS:
33
33
  raise Refused("bad_params", f"step {n}: {step['tool']!r} can't be a step; a step is one of "
34
34
  f"{', '.join(STEP_TOOLS)}")
35
- target = step.get("target")
35
+ target = step.get("tap") if step["tool"] == "performance" else step.get("target")
36
36
  if script and isinstance(target, dict) and "ref" in target:
37
37
  raise Refused("bad_params", f"step {n}: a script can't use refs (they don't outlive a session); use the "
38
38
  "element's id, label or text")
39
39
  if step["tool"] == "compare_screen":
40
40
  check_compare_step(step, n, script)
41
41
  if "expect" in step:
42
- check_expect(step["expect"], n, script)
42
+ check_expect(step["expect"], n, script, step["tool"])
43
43
  return steps
44
44
 
45
45
 
@@ -65,9 +65,18 @@ def _no_refs(ignore: object, n: int, script: bool) -> None:
65
65
  "the element's id, label or text")
66
66
 
67
67
 
68
- def check_expect(expect: object, n: int, script: bool = False) -> None:
68
+ def check_expect(expect: object, n: int, script: bool = False, tool: str = "") -> None:
69
69
  if not isinstance(expect, dict):
70
70
  raise Refused("bad_params", f"step {n}: expect must be an object, like {{\"text\": \"Saved\"}}")
71
+ if "under" in expect or tool == "performance":
72
+ # a performance step is checked against limits, and only a performance step has them
73
+ if tool != "performance" or set(expect) != {"under"}:
74
+ raise Refused("bad_params", f"step {n}: {performance.UNDER_ONLY}")
75
+ try:
76
+ performance.check_under(expect["under"])
77
+ except ValueError as e:
78
+ raise Refused("bad_params", f"step {n}: {e}") from None
79
+ return
71
80
  given = [k for k in EXPECT_KINDS if expect.get(k) is not None]
72
81
  matches = expect.get("matches") is not None
73
82
  if len(given) > 1 or (not given and not matches):
@@ -101,7 +110,7 @@ def args_of(step: dict) -> dict:
101
110
  """The tool call a step makes: its parameters without `tool` and `expect`, and without an answer of its own
102
111
  (the batch answers once)."""
103
112
  args = {k: v for k, v in step.items() if k not in ("tool", "expect", "route", "secure", "look", "screenshot")}
104
- if step["tool"] != "close_app":
113
+ if step["tool"] not in ("close_app", "performance"): # (a measurement has no list to show)
105
114
  args["look"] = False
106
115
  return args
107
116
 
@@ -125,6 +134,7 @@ def summary(step: dict) -> str:
125
134
  extra = {"scroll": step.get("to") or step.get("direction") or "down", "wait": step.get("seconds"),
126
135
  "open_app": step.get("app"), "close_app": step.get("app"), "open_url": step.get("url"),
127
136
  "alert": step.get("action"), "press_button": step.get("name"), "rotate": step.get("to"),
137
+ "performance": "launch" if step.get("launch") else step.get("seconds"),
128
138
  "compare_screen": json.dumps(step["name"], ensure_ascii=False) if isinstance(step.get("name"), str) else None,
129
139
  "wait_for": next((json.dumps(step[k], ensure_ascii=False) for k in EXPECT_KINDS if step.get(k)), None)}
130
140
  shown = extra.get(tool)
@@ -36,7 +36,7 @@ import sys
36
36
  import time
37
37
  from pathlib import Path
38
38
 
39
- from . import store
39
+ from . import diagnostics, store
40
40
  from .repair import Phone, run_fixes
41
41
 
42
42
 
@@ -508,7 +508,13 @@ def replay(a) -> int:
508
508
  except client.DaemonError as e:
509
509
  print(f"{f}: Error {e.code}: {e}")
510
510
  kind = "error" if e.code in START_CODES and not str(e).startswith("Ran ") else "failed"
511
- results.append((f, kind, f"{e.code}: {e}", time.monotonic() - began))
511
+ message = f"{e.code}: {e}"
512
+ if str(e).startswith("Ran ") and any(ln.startswith("Note: " + diagnostics.CRASHED) for ln in str(e).splitlines()):
513
+ # the app crashed: its report is pulled next to the script and named (AGENT-TOOLS §15.4)
514
+ if saved := pull_crash_beside(path):
515
+ message += "\n" + diagnostics.FAILURE_CRASH_FILE.format(path=saved)
516
+ print(f"{f}: {diagnostics.FAILURE_CRASH_FILE.format(path=saved)}")
517
+ results.append((f, kind, message, time.monotonic() - began))
512
518
  continue
513
519
  print(f"{f}: {answer.get('text', '')}")
514
520
  results.append((f, "ok", "", time.monotonic() - began))
@@ -518,6 +524,26 @@ def replay(a) -> int:
518
524
  return 2 if "error" in kinds else 1 if "failed" in kinds else 0
519
525
 
520
526
 
527
+ def pull_crash_beside(script: str) -> str | None:
528
+ """The app's newest crash report (the daemon's `crash` with pull), copied to
529
+ `<script dir>/<script name>.diff/<script name>.crash.ips`; its path, or None when there is none to copy."""
530
+ import re
531
+ import shutil
532
+ from . import client
533
+ try:
534
+ text = client.call("crash", {"pull": True}, "cli-replay", timeout=60.0).get("text", "")
535
+ saved = re.search(r"^Saved: (.+)$", text, re.M)
536
+ if saved is None:
537
+ return None
538
+ path = Path(script)
539
+ target = path.parent / f"{path.stem}.diff" / f"{path.stem}.crash.ips"
540
+ target.parent.mkdir(parents=True, exist_ok=True)
541
+ shutil.copyfile(saved.group(1), target)
542
+ return str(target)
543
+ except (client.DaemonError, OSError):
544
+ return None
545
+
546
+
521
547
  def write_junit(out: str, results: list[tuple[str, str, str, float]]) -> None:
522
548
  """One <testcase> per script; a stop is a <failure>, a script that couldn't start an <error>."""
523
549
  from xml.etree import ElementTree as ET
@@ -531,6 +557,8 @@ def write_junit(out: str, results: list[tuple[str, str, str, float]]) -> None:
531
557
  if kind != "ok":
532
558
  node = ET.SubElement(case, "failure" if kind == "failed" else "error", message=message.splitlines()[0])
533
559
  node.text = message
560
+ if output := diagnostics.junit_output(message): # the app's log lines and crash, for whoever reads the report
561
+ ET.SubElement(case, "system-out").text = output
534
562
  Path(out).parent.mkdir(parents=True, exist_ok=True)
535
563
  ET.ElementTree(suite).write(out, encoding="utf-8", xml_declaration=True)
536
564