Files
omarchycn/test/shell.d/screenshot-sanity-test.sh
T
David Heinemeier HanssonandClaude Fable 5 d2b090f8fc Isolate XDG dirs in shell tests that launch quickshell with a fake HOME
Faking HOME alone was never enough: the shell QML and the agent usage
updater read XDG_STATE_HOME and XDG_CACHE_HOME directly, so a test
quickshell inherited the session's real paths. The bar widget contract
test instantiated the agents widget, whose refresh ran the real
collectors against the empty fake HOME and wrote hollow "Waiting for
auth" records into the developer's real usage data files — hiding the
agents widget from their bar — while littering the real cache with
per-tmpdir scan files.

Point XDG_CONFIG_HOME, XDG_CACHE_HOME, and XDG_STATE_HOME under the fake
home in every test that boots quickshell with one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 17:13:39 +02:00

246 lines
6.3 KiB
Bash
Executable File

#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
TMPDIR=""
QS_PID=""
cleanup() {
if [[ -n $QS_PID ]] && kill -0 "$QS_PID" 2>/dev/null; then
kill "$QS_PID" 2>/dev/null || true
wait "$QS_PID" 2>/dev/null || true
fi
[[ -n $TMPDIR && -d $TMPDIR ]] && rm -rf "$TMPDIR"
}
trap cleanup EXIT
if [[ -z ${WAYLAND_DISPLAY:-} ]]; then
pass "no Wayland compositor; skipping screenshot sanity test"
exit 0
fi
if ! command -v quickshell >/dev/null 2>&1; then
pass "quickshell not installed; skipping screenshot sanity test"
exit 0
fi
if pgrep -x slurp >/dev/null 2>&1; then
pass "slurp is already running; skipping screenshot sanity test"
exit 0
fi
require_command hyprctl
require_command grim
require_command jq
require_command python3
shell_ipc() {
OMARCHY_PATH="$test_root" "$ROOT/bin/omarchy-shell" "$@"
}
shell_ipc_quiet() {
OMARCHY_PATH="$test_root" "$ROOT/bin/omarchy-shell" -q "$@"
}
fail_with_log() {
local description="$1"
sed -n '1,220p' "$log" >&2
[[ -f $screenshot_err ]] && sed -n '1,120p' "$screenshot_err" >&2
fail "$description"
}
TMPDIR=$(mktemp -d)
test_root="$TMPDIR/omarchy"
test_home="$TMPDIR/home"
stub_bin="$TMPDIR/bin"
screenshot_dir="$TMPDIR/screenshots"
log="$TMPDIR/quickshell.log"
screenshot_err="$TMPDIR/screenshot.err"
mkdir -p "$test_root" "$test_home" "$stub_bin" "$screenshot_dir"
cp -a "$ROOT/shell" "$test_root/shell"
ln -s "$ROOT/config" "$test_root/config"
ln -s "$ROOT/bin" "$test_root/bin"
cat >"$stub_bin/omarchy-update-available" <<'SH'
#!/bin/bash
echo "Omarchy update available (test)"
exit 0
SH
chmod +x "$stub_bin/omarchy-update-available"
cat >"$stub_bin/curl" <<'SH'
#!/bin/bash
case "${*: -1}" in
*'?format=j1')
printf '{"current_condition":[{"weatherCode":"113","temp_F":"72"}]}\n'
;;
*'?format=%l')
printf 'Test City, Test Region\n'
;;
*)
exit 1
;;
esac
SH
chmod +x "$stub_bin/curl"
OMARCHY_PATH="$test_root" \
HOME="$test_home" \
XDG_CONFIG_HOME="$test_home/.config" \
XDG_CACHE_HOME="$test_home/.cache" \
XDG_STATE_HOME="$test_home/.local/state" \
PATH="$stub_bin:$ROOT/bin:$PATH" \
quickshell -p "$test_root/shell" --no-color >"$log" 2>&1 &
QS_PID=$!
for _ in {1..80}; do
if shell_ipc_quiet shell ping >/dev/null 2>&1; then
break
fi
if ! kill -0 "$QS_PID" 2>/dev/null; then
fail_with_log "screenshot test shell exited before IPC became available"
fi
sleep 0.1
done
shell_ipc_quiet omarchy.system-update refresh >/dev/null 2>&1 || true
sleep 0.8
geometry=$(shell_ipc shell debugBarGeometry)
jq -e '
any(.[]; .id == "omarchy.menu" and .visible == true and .width > 0 and .height > 0) and
any(.[]; .id == "omarchy.clock" and .visible == true and .width > 0 and .height > 0)
' <<<"$geometry" >/dev/null || {
printf 'Geometry:\n' >&2
jq . <<<"$geometry" >&2
fail_with_log "screenshot test shell rendered visible bar widgets"
}
screenshot=$(
OMARCHY_PATH="$test_root" \
OMARCHY_SCREENSHOT_DIR="$screenshot_dir" \
HOME="$test_home" \
PATH="$stub_bin:$ROOT/bin:$PATH" \
"$ROOT/bin/omarchy" capture screenshot fullscreen save 2>"$screenshot_err" | tail -n 1
)
[[ -n $screenshot && -f $screenshot ]] || fail_with_log "fullscreen screenshot was captured"
SCREENSHOT="$screenshot" GEOMETRY="$geometry" python3 <<'PY'
import json
import os
import struct
import sys
import zlib
path = os.environ["SCREENSHOT"]
geometry = json.loads(os.environ["GEOMETRY"])
with open(path, "rb") as fh:
data = fh.read()
if data[:8] != b"\x89PNG\r\n\x1a\n":
print("screenshot is not a PNG", file=sys.stderr)
sys.exit(1)
offset = 8
width = height = bit_depth = color_type = None
idat = bytearray()
while offset < len(data):
length = struct.unpack(">I", data[offset:offset + 4])[0]
kind = data[offset + 4:offset + 8]
payload = data[offset + 8:offset + 8 + length]
offset += length + 12
if kind == b"IHDR":
width, height, bit_depth, color_type, compression, filtering, interlace = struct.unpack(">IIBBBBB", payload)
if bit_depth != 8 or compression != 0 or filtering != 0 or interlace != 0:
print("unsupported PNG format", file=sys.stderr)
sys.exit(1)
elif kind == b"IDAT":
idat.extend(payload)
elif kind == b"IEND":
break
if not width or not height:
print("missing PNG dimensions", file=sys.stderr)
sys.exit(1)
channels = {0: 1, 2: 3, 6: 4}.get(color_type)
if channels is None:
print(f"unsupported PNG color type: {color_type}", file=sys.stderr)
sys.exit(1)
raw = zlib.decompress(bytes(idat))
stride = width * channels
rows = []
pos = 0
prev = [0] * stride
def paeth(a, b, c):
p = a + b - c
pa = abs(p - a)
pb = abs(p - b)
pc = abs(p - c)
if pa <= pb and pa <= pc:
return a
if pb <= pc:
return b
return c
for _ in range(height):
filter_type = raw[pos]
pos += 1
scan = list(raw[pos:pos + stride])
pos += stride
out = [0] * stride
for i, value in enumerate(scan):
left = out[i - channels] if i >= channels else 0
up = prev[i]
up_left = prev[i - channels] if i >= channels else 0
if filter_type == 0:
out[i] = value
elif filter_type == 1:
out[i] = (value + left) & 255
elif filter_type == 2:
out[i] = (value + up) & 255
elif filter_type == 3:
out[i] = (value + ((left + up) // 2)) & 255
elif filter_type == 4:
out[i] = (value + paeth(left, up, up_left)) & 255
else:
print(f"unsupported PNG filter: {filter_type}", file=sys.stderr)
sys.exit(1)
rows.append(out)
prev = out
visible_heights = [int(row.get("height", 0)) for row in geometry if row.get("visible") and row.get("height", 0) > 0]
bar_height = max(24, min(80, (max(visible_heights) if visible_heights else 26) + 8))
bar_height = min(bar_height, height)
colors = set()
non_black = 0
sample_step_x = max(1, width // 320)
sample_step_y = max(1, bar_height // 12)
for y in range(0, bar_height, sample_step_y):
row = rows[y]
for x in range(0, width, sample_step_x):
i = x * channels
if color_type == 0:
rgb = (row[i], row[i], row[i])
else:
rgb = tuple(row[i:i + 3])
colors.add(rgb)
if max(rgb) > 8:
non_black += 1
if len(colors) < 3 or non_black == 0:
print(f"top bar band looked blank: colors={len(colors)} non_black={non_black} size={width}x{height}", file=sys.stderr)
sys.exit(1)
PY
pass "fullscreen screenshot shows a nonblank bar band"