T16: screencapture verify_render script for post-T11/T12/T13 sanity
Boots release ely_app, waits 8s, screencaptures full screen, verifies: - file >10KB, dims >100x100 via sips - center 128x128 patch not ~white (via sips crop + stdlib PNG decode) - stderr captured to /tmp/ely-verify-stderr.log on crash Runs idempotently (kills stale ely_app on entry + trap cleanup on exit). No new deps; pure bash + macOS sips + /usr/bin/python3 stdlib. First real run: PASS, screenshot /tmp/ely-verify-20260511-005449.png, center RGB ~(243,240,236) — app chrome paints, but web surface still shows the GPUI welcome panel (no Servo content) — exactly what T15's paint barrier should fix.
This commit is contained in:
Executable
+203
@@ -0,0 +1,203 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# T16 — real-window screencapture sanity check.
|
||||||
|
#
|
||||||
|
# Boots ./target/release/ely_app in the background, lets it open about:blank,
|
||||||
|
# grabs a screencapture, then asserts the PNG is non-trivial (size + dimensions
|
||||||
|
# + non-white center). Stderr from the app is tee'd to a log so a crash leaves
|
||||||
|
# evidence behind.
|
||||||
|
#
|
||||||
|
# Idempotent: kills any leftover ely_app processes from prior runs before
|
||||||
|
# starting, and cleans up its own background process on exit (success or fail).
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
SHOT_PATH="/tmp/ely-verify-${TIMESTAMP}.png"
|
||||||
|
STDERR_LOG="/tmp/ely-verify-stderr.log"
|
||||||
|
APP_BIN="${REPO_ROOT}/target/release/ely_app"
|
||||||
|
APP_PID=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "${APP_PID}" ]] && kill -0 "${APP_PID}" 2>/dev/null; then
|
||||||
|
kill "${APP_PID}" 2>/dev/null || true
|
||||||
|
# give it a beat to exit cleanly, then SIGKILL if needed
|
||||||
|
for _ in 1 2 3 4 5; do
|
||||||
|
kill -0 "${APP_PID}" 2>/dev/null || break
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
kill -9 "${APP_PID}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
# Stragglers from prior runs / child processes
|
||||||
|
pkill -f "target/release/ely_app" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
if [[ -s "${STDERR_LOG}" ]]; then
|
||||||
|
echo "---- stderr head (50 lines) ----" >&2
|
||||||
|
head -n 50 "${STDERR_LOG}" >&2
|
||||||
|
echo "--------------------------------" >&2
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[1/6] killing stale ely_app processes"
|
||||||
|
pkill -f "target/release/ely_app" 2>/dev/null || true
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
|
echo "[2/6] cargo build --release -p ely_app"
|
||||||
|
if ! cargo build --release -p ely_app; then
|
||||||
|
fail "cargo build failed"
|
||||||
|
fi
|
||||||
|
[[ -x "${APP_BIN}" ]] || fail "binary missing: ${APP_BIN}"
|
||||||
|
|
||||||
|
echo "[3/6] launching ${APP_BIN} (stderr -> ${STDERR_LOG})"
|
||||||
|
: > "${STDERR_LOG}"
|
||||||
|
"${APP_BIN}" >/dev/null 2>"${STDERR_LOG}" &
|
||||||
|
APP_PID=$!
|
||||||
|
echo " pid=${APP_PID}"
|
||||||
|
|
||||||
|
echo "[4/6] waiting 8s for window + about:blank to settle"
|
||||||
|
for i in 1 2 3 4 5 6 7 8; do
|
||||||
|
sleep 1
|
||||||
|
if ! kill -0 "${APP_PID}" 2>/dev/null; then
|
||||||
|
fail "app exited early during warm-up (after ${i}s)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[5/6] screencapture -> ${SHOT_PATH}"
|
||||||
|
# -x silences the shutter sound. Full screen is safer than -l <windowid> here
|
||||||
|
# because we don't have a stable Cocoa window id; the app paints into the
|
||||||
|
# primary display and that's what we care about.
|
||||||
|
if ! screencapture -x "${SHOT_PATH}"; then
|
||||||
|
fail "screencapture invocation failed"
|
||||||
|
fi
|
||||||
|
[[ -f "${SHOT_PATH}" ]] || fail "screencapture produced no file"
|
||||||
|
|
||||||
|
echo "[6/6] verifying screenshot"
|
||||||
|
|
||||||
|
# size > 10KB
|
||||||
|
BYTES=$(stat -f%z "${SHOT_PATH}")
|
||||||
|
echo " size: ${BYTES} bytes"
|
||||||
|
if (( BYTES <= 10240 )); then
|
||||||
|
fail "screenshot too small (${BYTES} bytes <= 10KB) — likely blank/failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# dimensions > 100x100
|
||||||
|
PIX_W=$(sips -g pixelWidth "${SHOT_PATH}" | awk '/pixelWidth:/ {print $2}')
|
||||||
|
PIX_H=$(sips -g pixelHeight "${SHOT_PATH}" | awk '/pixelHeight:/ {print $2}')
|
||||||
|
echo " dims: ${PIX_W} x ${PIX_H}"
|
||||||
|
if [[ -z "${PIX_W}" || -z "${PIX_H}" ]]; then
|
||||||
|
fail "could not read dimensions via sips"
|
||||||
|
fi
|
||||||
|
if (( PIX_W <= 100 || PIX_H <= 100 )); then
|
||||||
|
fail "screenshot dimensions too small (${PIX_W}x${PIX_H})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Center-region non-white check. Crops a center patch with sips (built-in on
|
||||||
|
# macOS), then decodes the resulting PNG via python3 stdlib only (zlib +
|
||||||
|
# struct) — no PyObjC / no new deps. Averages RGB; FAILs if all channels
|
||||||
|
# > 248, which would mean we captured an empty white desktop.
|
||||||
|
CENTER_PATCH="${SHOT_PATH%.png}-center.png"
|
||||||
|
PATCH_PX=128
|
||||||
|
PATCH_OFF_W=$(( PIX_W / 2 - PATCH_PX / 2 ))
|
||||||
|
PATCH_OFF_H=$(( PIX_H / 2 - PATCH_PX / 2 ))
|
||||||
|
if ! sips --cropToHeightWidth "${PATCH_PX}" "${PATCH_PX}" \
|
||||||
|
--cropOffset "${PATCH_OFF_H}" "${PATCH_OFF_W}" \
|
||||||
|
"${SHOT_PATH}" --out "${CENTER_PATCH}" >/dev/null 2>&1; then
|
||||||
|
fail "sips crop failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
PY_OUT=$(/usr/bin/python3 - "${CENTER_PATCH}" <<'PYEOF'
|
||||||
|
import sys, struct, zlib
|
||||||
|
|
||||||
|
path = sys.argv[1]
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
if data[:8] != b"\x89PNG\r\n\x1a\n":
|
||||||
|
print("SKIP not-png"); sys.exit(0)
|
||||||
|
|
||||||
|
i = 8
|
||||||
|
width = height = bit_depth = color_type = None
|
||||||
|
idat = bytearray()
|
||||||
|
while i < len(data):
|
||||||
|
length = struct.unpack(">I", data[i:i+4])[0]
|
||||||
|
ctype = data[i+4:i+8]
|
||||||
|
payload = data[i+8:i+8+length]
|
||||||
|
i += 8 + length + 4 # skip crc
|
||||||
|
if ctype == b"IHDR":
|
||||||
|
width, height, bit_depth, color_type = struct.unpack(">IIBB", payload[:10])
|
||||||
|
elif ctype == b"IDAT":
|
||||||
|
idat.extend(payload)
|
||||||
|
elif ctype == b"IEND":
|
||||||
|
break
|
||||||
|
|
||||||
|
if bit_depth != 8 or color_type not in (2, 6):
|
||||||
|
print(f"SKIP unsupported bit_depth={bit_depth} color_type={color_type}")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
channels = 3 if color_type == 2 else 4
|
||||||
|
stride = width * channels
|
||||||
|
raw = zlib.decompress(bytes(idat))
|
||||||
|
# undo PNG row filters
|
||||||
|
out = bytearray(width * height * channels)
|
||||||
|
prev = bytearray(stride)
|
||||||
|
pos = 0
|
||||||
|
for y in range(height):
|
||||||
|
f = raw[pos]; pos += 1
|
||||||
|
row = bytearray(raw[pos:pos+stride]); pos += stride
|
||||||
|
if f == 0:
|
||||||
|
pass
|
||||||
|
elif f == 1: # Sub
|
||||||
|
for x in range(channels, stride):
|
||||||
|
row[x] = (row[x] + row[x-channels]) & 0xFF
|
||||||
|
elif f == 2: # Up
|
||||||
|
for x in range(stride):
|
||||||
|
row[x] = (row[x] + prev[x]) & 0xFF
|
||||||
|
elif f == 3: # Average
|
||||||
|
for x in range(stride):
|
||||||
|
left = row[x-channels] if x >= channels else 0
|
||||||
|
row[x] = (row[x] + (left + prev[x]) // 2) & 0xFF
|
||||||
|
elif f == 4: # Paeth
|
||||||
|
for x in range(stride):
|
||||||
|
a = row[x-channels] if x >= channels else 0
|
||||||
|
b = prev[x]
|
||||||
|
c = prev[x-channels] if x >= channels else 0
|
||||||
|
p = a + b - c
|
||||||
|
pa, pb, pc = abs(p-a), abs(p-b), abs(p-c)
|
||||||
|
pr = a if pa <= pb and pa <= pc else (b if pb <= pc else c)
|
||||||
|
row[x] = (row[x] + pr) & 0xFF
|
||||||
|
else:
|
||||||
|
print(f"SKIP filter={f}"); sys.exit(0)
|
||||||
|
out[y*stride:(y+1)*stride] = row
|
||||||
|
prev = row
|
||||||
|
|
||||||
|
rs = gs = bs = n = 0
|
||||||
|
for y in range(height):
|
||||||
|
base = y * stride
|
||||||
|
for x in range(width):
|
||||||
|
o = base + x * channels
|
||||||
|
rs += out[o]; gs += out[o+1]; bs += out[o+2]; n += 1
|
||||||
|
ar, ag, ab = rs/n, gs/n, bs/n
|
||||||
|
print(f"CENTER {width}x{height} r={ar:.1f} g={ag:.1f} b={ab:.1f}")
|
||||||
|
if ar > 248 and ag > 248 and ab > 248:
|
||||||
|
sys.exit(2)
|
||||||
|
sys.exit(0)
|
||||||
|
PYEOF
|
||||||
|
)
|
||||||
|
PY_RC=$?
|
||||||
|
echo " center: ${PY_OUT}"
|
||||||
|
rm -f "${CENTER_PATCH}"
|
||||||
|
if (( PY_RC == 2 )); then
|
||||||
|
fail "center ${PATCH_PX}x${PATCH_PX} patch is ~white — likely empty desktop or failed paint"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "PASS"
|
||||||
|
echo "screenshot: ${SHOT_PATH}"
|
||||||
|
echo "stderr log: ${STDERR_LOG} ($(wc -l < "${STDERR_LOG}" | tr -d ' ') lines)"
|
||||||
|
exit 0
|
||||||
Reference in New Issue
Block a user