269 lines
11 KiB
Bash
Executable File
269 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
set -euo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
service="$repo_dir/config/dot/quickshell/services/Displays.qml"
|
|
harness="$repo_dir/config/dot/quickshell/displays-harness.qml"
|
|
|
|
fail() {
|
|
printf 'display transaction contract: %s\n' "$1" >&2
|
|
exit 1
|
|
}
|
|
|
|
for contract in \
|
|
'property var pendingPreviousLayout: null' \
|
|
'property var pendingRequestedLayout: null' \
|
|
'property var revertExpectedLayout: null' \
|
|
'function currentLayout()' \
|
|
'function applyLayout(layout:' \
|
|
'function makePrimary(output:' \
|
|
'function matchesLayout(monitors:' \
|
|
'function pushLayout(layout:'; do
|
|
rg -Fq "$contract" "$service" \
|
|
|| fail "complete-layout service boundary is missing: $contract"
|
|
done
|
|
|
|
rg -Fq 'position = "${record.x}x${record.y}"' "$service" \
|
|
|| fail 'the compositor payload does not include explicit positions'
|
|
rg -Fq 'generation === root.operationGeneration' "$service" \
|
|
|| fail 'stale monitor readback can settle a newer transaction'
|
|
rg -Fq 'function applyLayoutFixture(' "$harness" \
|
|
|| fail 'the fixture cannot exercise complete layout transactions'
|
|
rg -Fq 'function injectReadback(' "$harness" \
|
|
|| fail 'the fixture cannot prove stale readback isolation'
|
|
|
|
fixture="$(mktemp -d /tmp/panama-display-transaction.XXXXXX)"
|
|
state_home="$fixture/state-home"
|
|
config_home="$fixture/config-home"
|
|
fake_bin="$fixture/bin"
|
|
monitor_state="$fixture/monitors.json"
|
|
eval_log="$fixture/eval.log"
|
|
harness_pid=""
|
|
mkdir -p "$state_home" "$config_home" "$fake_bin"
|
|
|
|
cat >"$monitor_state" <<'JSON'
|
|
[
|
|
{
|
|
"name":"DP-2","description":"Primary fixture","width":4500,"height":3000,
|
|
"refreshRate":60,"scale":1.5,"transform":0,"x":0,"y":0,
|
|
"availableModes":["[email protected]"]
|
|
},
|
|
{
|
|
"name":"HDMI-A-1","description":"Second fixture","width":2560,"height":1440,
|
|
"refreshRate":60,"scale":1,"transform":0,"x":3000,"y":0,
|
|
"availableModes":["[email protected]"]
|
|
}
|
|
]
|
|
JSON
|
|
|
|
cat >"$fake_bin/hyprctl" <<'SH'
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
fixture="${PANAMA_DISPLAY_FIXTURE:?}"
|
|
if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then
|
|
cat "$fixture/monitors.json"
|
|
exit 0
|
|
fi
|
|
if [[ "${1:-}" != "eval" ]]; then
|
|
exit 2
|
|
fi
|
|
|
|
payload="${2:-}"
|
|
printf '%s\n' "$payload" >>"$fixture/eval.log"
|
|
if [[ -f "$fixture/fail-once" ]]; then
|
|
rm -f "$fixture/fail-once"
|
|
exit 1
|
|
fi
|
|
[[ -f "$fixture/no-apply" ]] && exit 0
|
|
|
|
python3 - "$fixture/monitors.json" "$payload" "$fixture/wrong-y" <<'PY'
|
|
import json
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
state_path = pathlib.Path(sys.argv[1])
|
|
payload = sys.argv[2]
|
|
wrong_y = pathlib.Path(sys.argv[3]).exists()
|
|
monitors = json.loads(state_path.read_text(encoding="utf-8"))
|
|
by_name = {monitor["name"]: monitor for monitor in monitors}
|
|
|
|
for block in re.findall(r"hl\.monitor\(\{([^}]*)\}\)", payload):
|
|
def field(pattern: str) -> str:
|
|
match = re.search(pattern, block)
|
|
if not match:
|
|
raise SystemExit(f"missing field {pattern}: {block}")
|
|
return match.group(1)
|
|
|
|
name = field(r'output\s*=\s*"([A-Za-z0-9_.-]+)"')
|
|
mode = field(r'mode\s*=\s*"(\d+x\d+@\d+(?:\.\d+)?)"')
|
|
position = re.search(r'position\s*=\s*"(-?\d+)x(-?\d+)"', block)
|
|
if not position or name not in by_name:
|
|
raise SystemExit(f"bad output or position: {block}")
|
|
width, height, refresh = re.match(r"(\d+)x(\d+)@(\d+(?:\.\d+)?)", mode).groups()
|
|
monitor = by_name[name]
|
|
monitor.update({
|
|
"width": int(width),
|
|
"height": int(height),
|
|
"refreshRate": float(refresh),
|
|
"scale": float(field(r"scale\s*=\s*([0-9.]+)")),
|
|
"transform": int(field(r"transform\s*=\s*(\d+)")),
|
|
"x": int(position.group(1)),
|
|
"y": int(position.group(2)),
|
|
})
|
|
if wrong_y and name == "HDMI-A-1":
|
|
monitor["y"] += 1
|
|
|
|
state_path.write_text(json.dumps(monitors), encoding="utf-8")
|
|
PY
|
|
SH
|
|
chmod +x "$fake_bin/hyprctl"
|
|
|
|
export PANAMA_DISPLAY_FIXTURE="$fixture"
|
|
|
|
run() {
|
|
PATH="$fake_bin:$PATH" XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" \
|
|
qs -p "$harness" "$@"
|
|
}
|
|
|
|
transaction_status() {
|
|
run ipc --pid "$harness_pid" call displays-test transactionStatus
|
|
}
|
|
|
|
cleanup() {
|
|
[[ "$harness_pid" =~ ^[0-9]+$ ]] && kill "$harness_pid" 2>/dev/null || true
|
|
rm -rf "$fixture"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
PATH="$fake_bin:$PATH" XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" \
|
|
qs -p "$harness" --daemonize >/dev/null
|
|
for _ in $(seq 1 60); do
|
|
harness_pid="$(qs list --all 2>/dev/null | awk -v expected="$harness" '
|
|
/^Instance / {pid=""} /^[[:space:]]*Process ID:/ {pid=$3}
|
|
/^[[:space:]]*Config path:/ {path=$0; sub(/^[[:space:]]*Config path: /,"",path); if(path==expected) print pid}' | head -1)"
|
|
if [[ "$harness_pid" =~ ^[0-9]+$ ]]; then
|
|
ready="$(transaction_status 2>/dev/null || true)"
|
|
jq -e '.layout | length == 2' <<<"$ready" >/dev/null 2>&1 && break
|
|
fi
|
|
sleep 0.1
|
|
done
|
|
[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'fixture shell did not start'
|
|
|
|
wait_for() {
|
|
local expression="$1"
|
|
local value=""
|
|
for _ in $(seq 1 80); do
|
|
value="$(transaction_status)"
|
|
jq -e "$expression" <<<"$value" >/dev/null && { printf '%s' "$value"; return 0; }
|
|
sleep 0.1
|
|
done
|
|
fail "timed out waiting for $expression: $value"
|
|
}
|
|
|
|
# A complete request is one evaluator call carrying every connected output.
|
|
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 100)" == "true" ]] \
|
|
|| fail 'valid complete layout was refused'
|
|
wait_for '.canConfirm == true' >/dev/null
|
|
first_payload="$(sed -n '1p' "$eval_log")"
|
|
[[ "$(rg -o 'hl\.monitor' <<<"$first_payload" | wc -l)" == "2" ]] \
|
|
|| fail "layout was not sent as one complete payload: $first_payload"
|
|
rg -Fq 'output = "DP-2"' <<<"$first_payload" \
|
|
&& rg -Fq 'position = "0x0"' <<<"$first_payload" \
|
|
&& rg -Fq 'output = "HDMI-A-1"' <<<"$first_payload" \
|
|
&& rg -Fq 'position = "3000x100"' <<<"$first_payload" \
|
|
|| fail "layout payload omitted a literal output position: $first_payload"
|
|
|
|
[[ "$(run ipc --pid "$harness_pid" call displays-test confirmChange)" == "true" ]] \
|
|
|| fail 'verified complete layout could not be kept'
|
|
store="$config_home/panama/settings.json"
|
|
jq -e '.displays | length == 2
|
|
and .["DP-2"].x == 0 and .["DP-2"].y == 0 and .["DP-2"].primary == true
|
|
and .["HDMI-A-1"].x == 3000 and .["HDMI-A-1"].y == 100
|
|
and .["HDMI-A-1"].primary == false' "$store" >/dev/null \
|
|
|| fail 'confirmation did not persist one complete layout with one primary'
|
|
|
|
# Wrong readback never enables Keep; explicit revert restores both outputs.
|
|
touch "$fixture/wrong-y"
|
|
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 200)" == "true" ]] \
|
|
|| fail 'wrong-readback fixture could not start'
|
|
wait_for '.busy == false and .awaiting == true' >/dev/null
|
|
jq -e '.canConfirm == false' <<<"$(transaction_status)" >/dev/null \
|
|
|| fail 'Keep enabled while one output had the wrong y coordinate'
|
|
rm -f "$fixture/wrong-y"
|
|
run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null
|
|
wait_for '.busy == false and .awaiting == false' >/dev/null
|
|
jq -e '.[0].x == 0 and .[0].y == 0 and .[1].x == 3000 and .[1].y == 100' \
|
|
"$monitor_state" >/dev/null || fail 'explicit revert did not restore the complete previous layout'
|
|
|
|
# A stale pre-operation query may update visible data, but cannot settle the
|
|
# current generation or make Keep available.
|
|
touch "$fixture/no-apply"
|
|
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 250)" == "true" ]] \
|
|
|| fail 'stale-generation fixture could not start'
|
|
wait_for '.busy == false and .awaiting == true' >/dev/null
|
|
generation="$(transaction_status | jq -r .generation)"
|
|
stale_json="$(jq '.[1].y = 250' "$monitor_state")"
|
|
run ipc --pid "$harness_pid" call displays-test injectReadback "$stale_json" "$((generation - 1))" >/dev/null
|
|
jq -e '.canConfirm == false' <<<"$(transaction_status)" >/dev/null \
|
|
|| fail 'stale readback confirmed a newer operation'
|
|
[[ "$(run ipc --pid "$harness_pid" call displays-test confirmChange)" == "false" ]] \
|
|
|| fail 'Keep accepted a display change without a generation-matched readback'
|
|
rm -f "$fixture/no-apply"
|
|
run ipc --pid "$harness_pid" call displays-test expireApplyVerification >/dev/null
|
|
wait_for '.busy == false and .awaiting == false' >/dev/null
|
|
|
|
# A non-zero evaluator exit follows the same whole-layout recovery path.
|
|
touch "$fixture/fail-once"
|
|
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 300)" == "true" ]] \
|
|
|| fail 'failed-evaluator fixture could not start'
|
|
failed_state="$(wait_for '.busy == false and .awaiting == false')"
|
|
jq -e '.lastError | contains("rejected")' <<<"$failed_state" >/dev/null \
|
|
|| fail "failed apply did not retain a useful recovery message: $failed_state"
|
|
|
|
# If an output disconnects while a change is pending, the screen-model observer
|
|
# refreshes topology and rolls back with only the still-connected output. This
|
|
# intentionally does not call Displays.refresh() through the public harness API.
|
|
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 320)" == "true" ]] \
|
|
|| fail 'disconnect fixture could not start'
|
|
wait_for '.canConfirm == true' >/dev/null
|
|
jq '.[0:1]' "$monitor_state" >"$fixture/connected.json"
|
|
mv "$fixture/connected.json" "$monitor_state"
|
|
run ipc --pid "$harness_pid" call displays-test setScreenModel '["DP-2"]' >/dev/null
|
|
wait_for '(.layout | length == 1) and .awaiting == false and .busy == false' >/dev/null
|
|
[[ "$(transaction_status | jq -c .primaryFirst)" == '["DP-2"]' ]] \
|
|
|| fail "primary-first monitor list did not reconcile after hot-unplug: $(transaction_status)"
|
|
wait_for '.busy == false and .awaiting == false' >/dev/null
|
|
disconnect_payload="$(tail -1 "$eval_log")"
|
|
[[ "$(rg -o 'hl\.monitor' <<<"$disconnect_payload" | wc -l)" == "1" ]] \
|
|
&& ! rg -Fq 'HDMI-A-1' <<<"$disconnect_payload" \
|
|
|| fail "disconnect rollback targeted an absent output: $disconnect_payload"
|
|
|
|
# Restore the second fixture output without touching the real compositor.
|
|
jq '. + [{
|
|
"name":"HDMI-A-1","description":"Second fixture","width":2560,"height":1440,
|
|
"refreshRate":60,"scale":1,"transform":0,"x":3000,"y":100,
|
|
"availableModes":["[email protected]"]
|
|
}]' "$monitor_state" >"$fixture/reconnected.json"
|
|
mv "$fixture/reconnected.json" "$monitor_state"
|
|
run ipc --pid "$harness_pid" call displays-test refresh >/dev/null
|
|
wait_for '.layout | length == 2' >/dev/null
|
|
|
|
# A revert that exits zero but reads back wrong remains an explicit manual
|
|
# recovery error rather than pretending the desktop was restored.
|
|
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 400)" == "true" ]] \
|
|
|| fail 'bad-revert fixture could not start'
|
|
wait_for '.canConfirm == true' >/dev/null
|
|
touch "$fixture/wrong-y"
|
|
run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null
|
|
wait_for '.reverting != null' >/dev/null
|
|
run ipc --pid "$harness_pid" call displays-test expireRevertVerification >/dev/null
|
|
manual_state="$(transaction_status)"
|
|
jq -e '.busy == false and (.lastError | contains("restore it manually"))' \
|
|
<<<"$manual_state" >/dev/null \
|
|
|| fail "wrong revert readback was reported as restored: $manual_state"
|
|
|
|
printf 'display transaction contract: PASS\n'
|