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,42 @@
|
|
|
1
|
+
// Measure the real contrast of text in a screenshot: the WCAG ratio between the darkest and the lightest pixels in a box.
|
|
2
|
+
// XCUITest's accessibility audit reports "Contrast failed" on text drawn over glass, materials and shadows because it samples
|
|
3
|
+
// what lies under them; a reviewed exception needs the number the user actually sees.
|
|
4
|
+
//
|
|
5
|
+
// DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift tools/screenshot_contrast.swift <shot.png> <x0> <y0> <x1> <y1>
|
|
6
|
+
// -> "text 0.000 background 0.811 contrast 17.2:1"
|
|
7
|
+
//
|
|
8
|
+
// The box is in fractions of the image (0.25 0.79 0.75 0.825 = the middle half, 79-82.5% down). Put it around one line of
|
|
9
|
+
// text and a little of what is behind it. The 1st and 99th percentile luminances stand for the text and the background, so
|
|
10
|
+
// a few anti-aliased edge pixels do not decide it. Light text on a dark fill works the same way. Elsewhen 2026-10-28: the
|
|
11
|
+
// type-a-time read-back on the Liquid Glass sheet, reported "Contrast failed", measured 17.2:1.
|
|
12
|
+
import AppKit
|
|
13
|
+
|
|
14
|
+
let args = CommandLine.arguments
|
|
15
|
+
let fractions = args.count == 6 ? args[2...5].compactMap(Double.init) : []
|
|
16
|
+
guard fractions.count == 4 else {
|
|
17
|
+
print("usage: screenshot_contrast.swift <shot.png> <x0> <y0> <x1> <y1> (fractions of width and height)"); exit(2)
|
|
18
|
+
}
|
|
19
|
+
guard let image = NSImage(contentsOfFile: args[1])?.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
|
20
|
+
print("unreadable: \(args[1])"); exit(1)
|
|
21
|
+
}
|
|
22
|
+
let bitmap = NSBitmapImageRep(cgImage: image)
|
|
23
|
+
let width = Double(bitmap.pixelsWide), height = Double(bitmap.pixelsHigh)
|
|
24
|
+
let x0 = Int(fractions[0] * width), y0 = Int(fractions[1] * height), x1 = Int(fractions[2] * width), y1 = Int(fractions[3] * height)
|
|
25
|
+
guard x0 < x1, y0 < y1 else { print("empty box"); exit(2) }
|
|
26
|
+
|
|
27
|
+
/// WCAG relative luminance of one sRGB channel.
|
|
28
|
+
func linear(_ channel: CGFloat) -> Double {
|
|
29
|
+
let v = Double(channel)
|
|
30
|
+
return v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
var luminances: [Double] = []
|
|
34
|
+
for y in y0..<y1 {
|
|
35
|
+
for x in x0..<x1 {
|
|
36
|
+
guard let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.sRGB) else { continue }
|
|
37
|
+
luminances.append(0.2126 * linear(color.redComponent) + 0.7152 * linear(color.greenComponent) + 0.0722 * linear(color.blueComponent))
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
luminances.sort()
|
|
41
|
+
let dark = luminances[luminances.count / 100], light = luminances[luminances.count * 99 / 100]
|
|
42
|
+
print(String(format: "text %.3f background %.3f contrast %.1f:1", dark, light, (light + 0.05) / (dark + 0.05)))
|
qakit/tools/shots_gif.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Turn a run's screenshots into one animated GIF: the demo, the PR comment, the bug report.
|
|
3
|
+
|
|
4
|
+
python3 tools/shots_gif.py <shots-dir> --out walk.gif # every PNG in index.md order
|
|
5
|
+
python3 tools/shots_gif.py <shots-dir> --out walk.gif --match "settings" --width 360 --ms 700 --max 40
|
|
6
|
+
python3 tools/shots_gif.py a.png b.png c.png --out variants.gif --ms 1200 # explicit frames (dark, large text, ...)
|
|
7
|
+
|
|
8
|
+
Reads the `index.md` that `device_qa.py shots` writes (one section per test, attachments in order) and keeps the PNGs
|
|
9
|
+
whose name matches `--match` (a regular expression against the attachment's name, case-insensitive). Needs Pillow
|
|
10
|
+
(`pip install pillow`); the kit's other tools do not. Frames are scaled to `--width`, padded to the tallest frame so a
|
|
11
|
+
rotated or shorter screenshot does not jump, and each frame shows for `--ms` milliseconds (the last one twice as long).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
ENTRY = re.compile(r"^- (?P<name>.*?): `(?P<file>[^`]+)`", re.M)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ordered_frames(shots_dir: Path, match: str | None = None, limit: int = 0) -> list[Path]:
|
|
25
|
+
"""PNG paths in index.md order (fallback: sorted directory listing), filtered by `match` against the attachment name."""
|
|
26
|
+
index = shots_dir / "index.md"
|
|
27
|
+
pattern = re.compile(match, re.I) if match else None
|
|
28
|
+
frames: list[Path] = []
|
|
29
|
+
if index.is_file():
|
|
30
|
+
for m in ENTRY.finditer(index.read_text(encoding="utf-8")):
|
|
31
|
+
path = shots_dir / m.group("file")
|
|
32
|
+
if path.suffix.lower() == ".png" and path.is_file() and (pattern is None or pattern.search(m.group("name"))):
|
|
33
|
+
frames.append(path)
|
|
34
|
+
else:
|
|
35
|
+
frames = [p for p in sorted(shots_dir.glob("*.png")) if pattern is None or pattern.search(p.stem)]
|
|
36
|
+
return frames[:limit] if limit else frames
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def write_gif(frames: list[Path], out: Path, width: int = 360, ms: int = 700) -> int:
|
|
40
|
+
try:
|
|
41
|
+
from PIL import Image
|
|
42
|
+
except ImportError:
|
|
43
|
+
raise SystemExit("shots_gif.py needs Pillow: pip install pillow")
|
|
44
|
+
images = []
|
|
45
|
+
for path in frames:
|
|
46
|
+
im = Image.open(path).convert("RGB")
|
|
47
|
+
scale = width / im.width
|
|
48
|
+
images.append(im.resize((width, max(1, round(im.height * scale)))))
|
|
49
|
+
if not images:
|
|
50
|
+
raise SystemExit("no frames")
|
|
51
|
+
height = max(im.height for im in images)
|
|
52
|
+
canvas = []
|
|
53
|
+
for im in images:
|
|
54
|
+
page = Image.new("RGB", (width, height), (16, 23, 42))
|
|
55
|
+
page.paste(im, (0, (height - im.height) // 2))
|
|
56
|
+
canvas.append(page.quantize(colors=128))
|
|
57
|
+
durations = [ms] * len(canvas)
|
|
58
|
+
durations[-1] = ms * 2
|
|
59
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
canvas[0].save(out, save_all=True, append_images=canvas[1:], duration=durations, loop=0, optimize=True)
|
|
61
|
+
return len(canvas)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main(argv: list[str] | None = None) -> int:
|
|
65
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
66
|
+
parser.add_argument("inputs", nargs="+", help="a shots folder (with index.md) or PNG files")
|
|
67
|
+
parser.add_argument("--out", required=True)
|
|
68
|
+
parser.add_argument("--match", help="keep attachments whose name matches this regex (folder input only)")
|
|
69
|
+
parser.add_argument("--width", type=int, default=360)
|
|
70
|
+
parser.add_argument("--ms", type=int, default=700)
|
|
71
|
+
parser.add_argument("--max", type=int, default=0, help="at most this many frames (0 = all)")
|
|
72
|
+
args = parser.parse_args(argv)
|
|
73
|
+
paths = [Path(p) for p in args.inputs]
|
|
74
|
+
frames = ordered_frames(paths[0], args.match, args.max) if len(paths) == 1 and paths[0].is_dir() else [p for p in paths if p.is_file()]
|
|
75
|
+
n = write_gif(frames, Path(args.out), args.width, args.ms)
|
|
76
|
+
print(f"{n} frames -> {args.out}")
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
sys.exit(main()) # mutation-ok: entry point, pytest calls main() directly
|