qa-agent-kit 0.6.0__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.
- qa_agent_kit-0.6.0.dist-info/METADATA +157 -0
- qa_agent_kit-0.6.0.dist-info/RECORD +32 -0
- qa_agent_kit-0.6.0.dist-info/WHEEL +4 -0
- qa_agent_kit-0.6.0.dist-info/entry_points.txt +6 -0
- qa_agent_kit-0.6.0.dist-info/licenses/LICENSE +21 -0
- qakit/AGENTS.md +48 -0
- qakit/__init__.py +21 -0
- qakit/_cli.py +46 -0
- qakit/docs/README.md +14 -0
- qakit/docs/agents.md +40 -0
- qakit/docs/case-studies.md +146 -0
- qakit/docs/device-qa-protocol.md +426 -0
- qakit/docs/getting-started.md +100 -0
- qakit/docs/ko/device-qa-protocol.md +433 -0
- qakit/docs/manifest.md +96 -0
- qakit/docs/mutation-testing.md +69 -0
- qakit/docs/prose-qa.md +49 -0
- qakit/docs/pypi.md +40 -0
- qakit/docs/report.md +64 -0
- qakit/skills/README.md +51 -0
- qakit/skills/device-qa/SKILL.md +87 -0
- qakit/skills/prose-qa/SKILL.md +49 -0
- qakit/skills/qa-rigor/SKILL.md +105 -0
- qakit/tools/device_lock.py +151 -0
- qakit/tools/device_qa.py +838 -0
- qakit/tools/install_skills.py +120 -0
- qakit/tools/mutate.py +684 -0
- qakit/tools/prose_check.py +291 -0
- qakit/tools/recording_frames.swift +78 -0
- qakit/tools/release_checks.py +287 -0
- qakit/tools/screenshot_contrast.swift +42 -0
- qakit/tools/shots_gif.py +81 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""One test iPhone, many sessions: a first-come lock around everything that drives the phone.
|
|
3
|
+
|
|
4
|
+
Two Claude sessions QA-ing two apps on the one USB iPhone (2026-10-28) fight over the foreground app and the phone-wide settings
|
|
5
|
+
(dark mode, text size, VoiceOver, simulated location): both runs fail, or pass on the wrong screen. `device_qa.py` holds this lock
|
|
6
|
+
for test, traits, perf, upgrade, release and devmode; a second session waits in arrival order and says who holds the phone.
|
|
7
|
+
Raw xcodebuild/devicectl/xctrace work goes through `device_qa.py lock run -- <command>`; `device_qa.py lock status` shows the queue.
|
|
8
|
+
|
|
9
|
+
The lock is flock(2) on ~/.qakit/device-qa/<udid>.lock: the kernel drops it when the holder exits or crashes, so nothing goes
|
|
10
|
+
stale. flock alone is not first-come, so a waiting session leaves a ticket in <udid>.queue/ and only the oldest live ticket may
|
|
11
|
+
take the lock.
|
|
12
|
+
|
|
13
|
+
A holder puts QAKIT_DEVICE_HELD=<udid>:<pid> in its environment, so the commands it starts (`lock run -- <script>` whose
|
|
14
|
+
script calls `device_qa.py test`) share its turn instead of queueing behind their own parent until the timeout.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import datetime as dt
|
|
19
|
+
import fcntl
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Callable
|
|
26
|
+
|
|
27
|
+
LOCKS = Path(os.environ.get("QAKIT_DEVICE_LOCKS") or Path.home() / ".qakit/device-qa")
|
|
28
|
+
HELD = "QAKIT_DEVICE_HELD"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DeviceBusy(RuntimeError):
|
|
32
|
+
"""The phone is held and the caller asked not to wait (or waited past its timeout)."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def alive(pid: int) -> bool:
|
|
36
|
+
try:
|
|
37
|
+
os.kill(pid, 0)
|
|
38
|
+
except ProcessLookupError:
|
|
39
|
+
return False
|
|
40
|
+
except PermissionError: # someone else's process: alive
|
|
41
|
+
return True
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def ticket_pid(name: str) -> int:
|
|
46
|
+
return int(name.split("-", 1)[1].split(".", 1)[0])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def next_in_line(tickets: list[str], is_alive: Callable[[int], bool] = alive) -> str | None:
|
|
50
|
+
"""The oldest ticket ('<ns>-<pid>.json') whose process still runs: tickets sort by arrival time."""
|
|
51
|
+
for name in sorted(tickets, key=lambda n: int(n.split("-", 1)[0])):
|
|
52
|
+
if is_alive(ticket_pid(name)):
|
|
53
|
+
return name
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DeviceLock:
|
|
58
|
+
"""`with DeviceLock(udid, note):` holds the phone for the block. `note` says who (app, command, session) to those waiting."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, udid: str, note: str, *, wait: bool = True, timeout: float = 4 * 3600, poll: float = 5, root: Path | None = None,
|
|
61
|
+
log: Callable[[str], None] = lambda message: print(message, file=sys.stderr, flush=True)) -> None:
|
|
62
|
+
self.root = root or LOCKS
|
|
63
|
+
self.udid, self.note, self.wait, self.timeout, self.poll, self.log = udid or "no-device", note, wait, timeout, poll, log
|
|
64
|
+
self.fd: int | None = None
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def queue(self) -> Path:
|
|
68
|
+
return self.root / f"{self.udid}.queue"
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def holder_file(self) -> Path:
|
|
72
|
+
return self.root / f"{self.udid}.holder.json"
|
|
73
|
+
|
|
74
|
+
def holder(self) -> dict[str, Any] | None:
|
|
75
|
+
"""Who holds the phone now: the holder file of a process that still runs, else None."""
|
|
76
|
+
try:
|
|
77
|
+
info = json.loads(self.holder_file.read_text(encoding="utf-8"))
|
|
78
|
+
except (OSError, ValueError):
|
|
79
|
+
return None
|
|
80
|
+
return info if alive(int(info.get("pid", 0))) else None
|
|
81
|
+
|
|
82
|
+
def waiting(self) -> list[dict[str, Any]]:
|
|
83
|
+
"""Live tickets in arrival order."""
|
|
84
|
+
names = sorted((p.name for p in self.queue.glob("*.json")), key=lambda n: int(n.split("-", 1)[0])) if self.queue.is_dir() else []
|
|
85
|
+
out = []
|
|
86
|
+
for name in names:
|
|
87
|
+
if alive(ticket_pid(name)):
|
|
88
|
+
try:
|
|
89
|
+
out.append(json.loads((self.queue / name).read_text(encoding="utf-8")))
|
|
90
|
+
except (OSError, ValueError):
|
|
91
|
+
pass
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
def describe(self, info: dict[str, Any] | None) -> str:
|
|
95
|
+
return f"{info.get('note', '?')} (pid {info.get('pid')}, since {info.get('since', '?')})" if info else "another session"
|
|
96
|
+
|
|
97
|
+
def __enter__(self) -> "DeviceLock":
|
|
98
|
+
holder = self.holder()
|
|
99
|
+
if holder and holder.get("pid") != os.getpid() and os.environ.get(HELD) == f"{self.udid}:{holder.get('pid')}":
|
|
100
|
+
return self # started by the holder: its turn is ours (fd stays None, so __exit__ releases nothing)
|
|
101
|
+
# ponytail: the marker is inherited, not proven ancestry; a session that copies another's environment could jump the
|
|
102
|
+
# queue. Upgrade trigger: that happens once -> walk the parent chain (ps -o ppid) instead.
|
|
103
|
+
self.queue.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
self.fd = os.open(self.root / f"{self.udid}.lock", os.O_RDWR | os.O_CREAT, 0o644)
|
|
105
|
+
ticket = self.queue / f"{time.time_ns()}-{os.getpid()}.json"
|
|
106
|
+
ticket.write_text(json.dumps({"pid": os.getpid(), "note": self.note, "since": dt.datetime.now().strftime("%H:%M:%S")},
|
|
107
|
+
ensure_ascii=False), encoding="utf-8")
|
|
108
|
+
deadline, said = time.monotonic() + self.timeout, 0.0
|
|
109
|
+
try:
|
|
110
|
+
while True:
|
|
111
|
+
names = [p.name for p in self.queue.glob("*.json")]
|
|
112
|
+
for name in names: # tickets of sessions that died while waiting
|
|
113
|
+
if name != ticket.name and not alive(ticket_pid(name)):
|
|
114
|
+
(self.queue / name).unlink(missing_ok=True)
|
|
115
|
+
if next_in_line(names) == ticket.name:
|
|
116
|
+
try:
|
|
117
|
+
fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
118
|
+
break
|
|
119
|
+
except BlockingIOError:
|
|
120
|
+
pass
|
|
121
|
+
who = self.describe(self.holder())
|
|
122
|
+
if not self.wait or time.monotonic() > deadline:
|
|
123
|
+
raise DeviceBusy(f"the test phone is in use by {who}")
|
|
124
|
+
if time.monotonic() - said >= 60:
|
|
125
|
+
ahead = sum(1 for n in names if int(n.split("-", 1)[0]) < int(ticket.name.split("-", 1)[0]) and alive(ticket_pid(n)))
|
|
126
|
+
self.log(f"waiting for the test phone: held by {who}; {ahead} ahead in the queue")
|
|
127
|
+
said = time.monotonic()
|
|
128
|
+
time.sleep(self.poll)
|
|
129
|
+
except BaseException:
|
|
130
|
+
os.close(self.fd)
|
|
131
|
+
self.fd = None
|
|
132
|
+
raise
|
|
133
|
+
finally:
|
|
134
|
+
ticket.unlink(missing_ok=True)
|
|
135
|
+
self.holder_file.write_text(json.dumps({"pid": os.getpid(), "note": self.note, "since": dt.datetime.now().strftime("%H:%M:%S"),
|
|
136
|
+
"cwd": os.getcwd()}, ensure_ascii=False), encoding="utf-8")
|
|
137
|
+
os.environ[HELD] = f"{self.udid}:{os.getpid()}"
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
def __exit__(self, *exc: object) -> None:
|
|
141
|
+
if self.fd is None:
|
|
142
|
+
return
|
|
143
|
+
try:
|
|
144
|
+
if json.loads(self.holder_file.read_text(encoding="utf-8")).get("pid") == os.getpid():
|
|
145
|
+
self.holder_file.unlink(missing_ok=True)
|
|
146
|
+
except (OSError, ValueError):
|
|
147
|
+
pass
|
|
148
|
+
os.environ.pop(HELD, None)
|
|
149
|
+
fcntl.flock(self.fd, fcntl.LOCK_UN)
|
|
150
|
+
os.close(self.fd)
|
|
151
|
+
self.fd = None
|