Files
Panama/tests/hypr/window-rules-contract

218 lines
9.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# Per-application window rules, written by the user.
#
# This is settings.json describing compositor behaviour, which is the same
# shape of risk custom shortcuts carry and is handled the same way: the stored
# entry is data -- a class, some booleans, two numbers -- and hypr/rules.lua is
# the only thing that turns it into a rule.
#
# Three properties, and every one of them was a real way to lose the desktop:
#
# 1. The class is matched LITERALLY. Hyprland matches with RE2, so a class
# typed into a text field is a regular expression: "org.gnome.Files" would
# also match "orgxgnomexFiles", and a half-typed "(" is a pattern error
# rather than a rule that matches nothing.
# 2. An invalid entry is skipped WHOLE. A rule that half-applies -- the size
# dropped, the float kept -- is harder to understand than one that is not
# there.
# 3. The shell's own surfaces cannot be matched. A user floating or moving
# Quickshell's windows from the Windows page is a person breaking their
# desktop with a supported control.
#
# And one about ordering: user rules are ANONYMOUS. Hyprland evaluates every
# named rule before every anonymous one, so naming a user rule would make it
# lose to the shipped rules it is meant to override.
#
# The Lua is exercised with a stubbed `hl`, so the rules can be counted without
# a compositor and without touching the running desktop.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
hypr_dir="$repo_dir/config/dot/hypr"
rules="$hypr_dir/rules.lua"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
findings=()
note() { findings+=("$1"); }
command -v lua >/dev/null 2>&1 || {
printf 'window rules contract: lua is not installed\n' >&2
exit 1
}
work="$(mktemp -d /tmp/panama-window-rules.XXXXXX)"
trap 'rm -rf "$work"' EXIT
mkdir -p "$work/config/panama"
# Every window rule rules.lua emits for a given settings file, one per line, as
# "<name or -> <class pattern> <field=value ...>". Layer rules are swallowed:
# this is about window rules, and a layer rule is not one.
emit() {
printf '%s' "$1" >"$work/config/panama/settings.json"
XDG_CONFIG_HOME="$work/config" lua -e "
package.path = '$hypr_dir/?.lua;' .. package.path
hl = {
layer_rule = function() end,
window_rule = function(rule)
local fields = {}
for key, value in pairs(rule) do
if key ~= 'match' and key ~= 'name' then
if type(value) == 'table' then
value = tostring(value[1]) .. 'x' .. tostring(value[2])
end
fields[#fields + 1] = key .. '=' .. tostring(value)
end
end
table.sort(fields)
local class = (rule.match or {}).class
print((rule.name or '-') .. ' ' .. tostring(class) .. ' ' .. table.concat(fields, ' '))
end,
}
dofile('$rules')
" 2>/dev/null
}
count() { grep -c . <<<"$1" || true; }
# ── The shipped baseline ─────────────────────────────────────────────────────
#
# Read rather than pinned as a number. Pinning one means every new shipped rule
# fails this contract for no reason, and the property under test is the
# DIFFERENCE the user's rules make, not how many rules Panama ships.
shipped="$(emit '{"windowRules":[]}')"
shipped_count="$(count "$shipped")"
(( shipped_count > 0 )) || note 'rules.lua emitted no window rules at all, which cannot be right'
absent="$(emit '{}')"
(( "$(count "$absent")" == shipped_count )) \
|| note 'with no windowRules key at all, the shipped rule count changes'
# ── Five rules, two of which must not be emitted ──────────────────────────────
#
# * Calculator -- valid, and the one whose escaping is checked
# * qs-dock -- the shell's own surface, refused
# * Steam -- workspace 42, which does not exist; skipped whole
# * foo(bar -- a class that is not a valid regex, and must not become one
# * mygame -- valid, with every remaining field set
USER_RULES='{"windowRules":[
{"class":"org.gnome.Calculator","label":"Calculator","float":true,"center":true,"size":[400,600]},
{"class":"qs-dock","label":"Dock","float":true},
{"class":"Steam","label":"Steam","workspace":42},
{"class":"foo(bar","label":"Odd","float":true,"pin":true},
{"class":"mygame","label":"Game","game":true,"noAnim":true,"noDim":true,"workspace":9}
]}'
applied="$(emit "$USER_RULES")"
applied_count="$(count "$applied")"
(( applied_count == shipped_count + 3 )) \
|| note "five user rules with two invalid emitted $(( applied_count - shipped_count )) rules, not 3"
user_lines="$(comm -13 <(sort <<<"$shipped") <(sort <<<"$applied"))"
# 1. The class is escaped and anchored, so it matches itself and nothing else.
grep -qF '^org\.gnome\.Calculator$' <<<"$user_lines" \
|| note 'the class is not regex-escaped, so a dotted application id matches classes the user did not name'
grep -qF '^foo\(bar$' <<<"$user_lines" \
|| note 'a class containing a regex metacharacter is not escaped, so it is a pattern rather than a name'
# 2. Invalid entries are gone, and gone whole.
grep -q 'qs-dock' <<<"$user_lines" \
&& note 'a rule matching the shell\047s own surfaces was emitted'
grep -q 'Steam' <<<"$user_lines" \
&& note 'a rule with an out-of-range workspace was emitted'
grep -qE 'Steam.*float' <<<"$user_lines" \
&& note 'an invalid rule was emitted with its bad field dropped rather than skipped whole'
# 3. The valid ones carry what they were given, under Hyprland's own names.
grep -qE '\^org\\\.gnome\\\.Calculator\$.*center=true.*float=true.*size=400x600' <<<"$user_lines" \
|| note 'the float/center/size rule did not survive translation: '"$(grep Calculator <<<"$user_lines")"
grep -qE '\^mygame\$.*content=game.*no_anim=true.*no_dim=true.*workspace=9' <<<"$user_lines" \
|| note 'the game rule did not survive translation: '"$(grep mygame <<<"$user_lines")"
grep -qE '\^foo\\\(bar\$.*pin=true' <<<"$user_lines" \
|| note 'pin did not survive translation'
# 4. Anonymous, and after the shipped rules. Named would outrank every
# anonymous shipped rule; earlier would let a shipped rule win the last-match
# tiebreak against the user's own.
while IFS= read -r line; do
[[ -n "$line" ]] || continue
[[ "${line%% *}" == "-" ]] \
|| note "a user rule was emitted with the name '${line%% *}', which makes it outrank the shipped rules"
done <<<"$user_lines"
first_user="$(grep -n 'mygame' <<<"$applied" | head -1 | cut -d: -f1)"
last_shipped="$(grep -c . <<<"$shipped")"
[[ -n "$first_user" && "$first_user" -gt "$last_shipped" ]] \
|| note 'user rules are not emitted after every shipped rule'
# ── Sizes and workspaces have bounds ─────────────────────────────────────────
#
# Not decoration: a 4-pixel window is unreachable with the pointer, and a
# workspace number outside what the keybinds reach strands a window somewhere
# there is no shortcut to.
for bad in \
'{"class":"tiny","size":[4,4]}' \
'{"class":"huge","size":[99999,99999]}' \
'{"class":"half","size":[400]}' \
'{"class":"zero","workspace":0}' \
'{"class":"","float":true}' \
'{"class":"quickshell","float":true}' \
'{"class":"QS-Dock","float":true}'; do
out="$(emit "{\"windowRules\":[$bad]}")"
(( "$(count "$out")" == shipped_count )) \
|| note "an invalid rule was emitted anyway: $bad"
done
# A class of exactly the cap is fine; one past it is not.
ok_class="$(printf 'a%.0s' $(seq 1 128))"
long_class="$(printf 'a%.0s' $(seq 1 129))"
(( "$(count "$(emit "{\"windowRules\":[{\"class\":\"$ok_class\"}]}")")" == shipped_count + 1 )) \
|| note 'a class of exactly 128 characters is refused, so the cap is off by one'
(( "$(count "$(emit "{\"windowRules\":[{\"class\":\"$long_class\"}]}")")" == shipped_count )) \
|| note 'a class longer than 128 characters is emitted anyway'
# ── Nothing malformed can cost the compositor ────────────────────────────────
#
# The whole point of reading a hand-editable file at config time is that a bad
# read costs the setting and never the desktop. A raise here aborts rules.lua,
# and the desktop comes up with no window rules at all.
for hostile in \
'{"windowRules":"not an array"}' \
'{"windowRules":[null]}' \
'{"windowRules":[42]}' \
'{"windowRules":[{"class":123}]}' \
'{"windowRules":[{"class":"ok","size":"400x600"}]}' \
'{"windowRules":[{"class":"ok","workspace":"3"}]}'; do
out="$(emit "$hostile")"
(( "$(count "$out")" >= shipped_count )) \
|| note "a malformed windowRules value cost the shipped rules: $hostile"
done
# ── The preference cannot pretend to be an option ────────────────────────────
if grep -q 'key: "windowRules"' "$schema"; then
block="$(sed -n '/key: "windowRules"/,/^ },/p' "$schema")"
grep -q 'hypr:' <<<"$block" \
&& note 'windowRules declares a hypr option, but window rules are not settable options'
else
note 'windowRules is not in the schema'
fi
# ── Report ───────────────────────────────────────────────────────────────────
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'window rules contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'window rules contract: PASS (%d shipped rules, user rules validated and escaped)\n' "$shipped_count"