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.
- {tapwright-1.2.0/tapwright.egg-info → tapwright-1.3.0}/PKG-INFO +3 -1
- {tapwright-1.2.0 → tapwright-1.3.0}/README.md +2 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/pyproject.toml +1 -1
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/__init__.py +1 -1
- tapwright-1.3.0/tapwright/aio.py +56 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/batch.py +16 -6
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/cli.py +30 -2
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/daemon.py +657 -29
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/descriptions.py +32 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/device.py +422 -2
- tapwright-1.3.0/tapwright/diagnostics.py +421 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/mcp_server.py +18 -0
- tapwright-1.3.0/tapwright/performance.py +193 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/skill/SKILL.md +3 -1
- {tapwright-1.2.0 → tapwright-1.3.0/tapwright.egg-info}/PKG-INFO +3 -1
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright.egg-info/SOURCES.txt +8 -0
- tapwright-1.3.0/tests/test_aio.py +154 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_compare_screen.py +29 -0
- tapwright-1.3.0/tests/test_crash_reading.py +213 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_daemon.py +1 -1
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_descriptions.py +1 -1
- tapwright-1.3.0/tests/test_diagnostics.py +1217 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_mcp_tools.py +6 -1
- tapwright-1.3.0/tests/test_meter.py +362 -0
- tapwright-1.3.0/tests/test_performance.py +407 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/LICENSE +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/setup.cfg +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/__main__.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/actionlog.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/airplay.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/audit.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/changes.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/client.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/compare.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/connect.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/control.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/errors.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/filmstrip.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/mirror.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/ocr.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/policy.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/repair.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/rtp.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/server.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/session.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/shot.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/signing.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/skill/agents/openai.yaml +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/skill/references/setup.md +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/skills.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/store.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/stream.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/tapline.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/ui.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/video.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright/viewer.html +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright.egg-info/dependency_links.txt +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright.egg-info/entry_points.txt +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright.egg-info/requires.txt +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tapwright.egg-info/top_level.txt +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_actionlog.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_airplay.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_audit.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_batch.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_changes.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_cli.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_cli_replay.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_compare.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_connect.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_control.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_daemon_video.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_device.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_filmstrip.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_mcp.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_mirror.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_ocr.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_offline.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_policy.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_repair.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_rtp.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_serve.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_server.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_session.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_setup_resume.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_shot.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_skill.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_store.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_stream.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_stream_pictures.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_tapline_notice.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_targets.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_ui.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_ui_tools.py +0 -0
- {tapwright-1.2.0 → tapwright-1.3.0}/tests/test_video.py +0 -0
- {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.
|
|
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.
|
|
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
|
+
__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"]
|
|
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
|
-
|
|
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
|
|