#!/usr/bin/env bash # Serial tray/window/autostart validation in the private SNI-host rig. set -euo pipefail repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" out="${1:-${TMPDIR:-/tmp}/clipbin-background-validation}" mkdir -p "${out}" cargo build --locked -p clipbin-desktop -p rig-pointer cargo build --locked -p clipbin-core --example sni-host export CLIPBIN_BACKGROUND_REPO="${repo}" CLIPBIN_BACKGROUND_OUT="${out}" "${repo}/scripts/rig/headless.sh" bash <<'INNER' set -euo pipefail repo="${CLIPBIN_BACKGROUND_REPO}" out="${CLIPBIN_BACKGROUND_OUT}" export XDG_CONFIG_HOME="${out}/config" XDG_DATA_HOME="${out}/data" mkdir -p "${XDG_CONFIG_HOME}" "${XDG_DATA_HOME}" source "${repo}/scripts/rig/keys.sh" "${repo}/target/debug/examples/sni-host" > "${out}/host.log" 2>&1 & host=$! app= game= trap 'kill "${app:-}" "${game:-}" "${host:-}" 2>/dev/null || true' EXIT sleep 1 swaymsg 'for_window [app_id="dev.clipbin.Clipbin"] floating enable' >/dev/null swaymsg 'for_window [app_id="dev.clipbin.Clipbin"] resize set width 1360 px height 860 px' >/dev/null swaymsg 'for_window [app_id="dev.clipbin.Clipbin"] move position 280 110' >/dev/null windows() { swaymsg -t get_tree | python3 -c 'import json,sys nodes=[json.load(sys.stdin)]; total=0 while nodes: n=nodes.pop(); nodes.extend(n.get("nodes",[])+n.get("floating_nodes",[])); total+=n.get("app_id")=="dev.clipbin.Clipbin" print(total)' } wait_windows() { for _ in $(seq 50); do [[ "$(windows)" == "$1" ]] && return 0; sleep .2; done echo "FAIL expected $1 Clipbin windows" >&2; exit 1 } wait_exit() { for _ in $(seq 100); do if ! kill -0 "${app}" 2>/dev/null; then wait "${app}"; return; fi; sleep .2; done echo 'FAIL Quit did not exit' >&2; exit 1 } CLIPBIN_ROUTE=settings "${repo}/target/debug/clipbin-desktop" --background > "${out}/desktop.log" 2>&1 & app=$! sleep 3 kill -0 "${app}" [[ "$(windows)" == 0 ]] [[ -f "${XDG_CONFIG_HOME}/autostart/clipbin.desktop" ]] grep -q -- '--background' "${XDG_CONFIG_HOME}/autostart/clipbin.desktop" item="$(sed -n 's/^registered=//p' "${out}/host.log" | tail -1)" [[ -n "${item}" ]] gdbus call --session --dest org.kde.StatusNotifierWatcher --object-path /StatusNotifierWatcher \ --method org.freedesktop.DBus.Properties.Get org.kde.StatusNotifierWatcher RegisteredStatusNotifierItems > "${out}/registered.txt" grep -q "${item}" "${out}/registered.txt" gdbus call --session --dest "${item}" --object-path /MenuBar \ --method com.canonical.dbusmenu.GetLayout -- 0 -1 '[]' > "${out}/menu.txt" grep -q 'Open Clipbin' "${out}/menu.txt" menu() { gdbus call --session --dest "${item}" --object-path /MenuBar --method com.canonical.dbusmenu.Event "$1" clicked '<0>' 0 > /dev/null; } menu 1 wait_windows 1 swaymsg '[title="clipbin-test-game"] fullscreen disable' >/dev/null swaymsg '[app_id="dev.clipbin.Clipbin"] floating enable, resize set width 1360 px height 860 px, move position center, focus' >/dev/null sleep 1 grim "${out}/settings.png" # Coordinates within the centred 1360x860 settings window at 1920x1080. printf 'move 1330 550\nsleep .2\nclick\nsleep .3\n' | "${repo}/target/debug/rig-pointer" 1920 1080 sleep 1 [[ ! -f "${XDG_CONFIG_HOME}/autostart/clipbin.desktop" ]] printf 'move 1330 550\nsleep .2\nclick\nsleep .3\n' | "${repo}/target/debug/rig-pointer" 1920 1080 sleep 1 [[ -f "${XDG_CONFIG_HOME}/autostart/clipbin.desktop" ]] echo 'PASS SNI registration, menu Open, background startup and autostart setting' bash -c 'exec -a reaper python3 -c "import time; time.sleep(120)" SteamLaunch AppId=480 --' & game=$! for _ in $(seq 40); do grep -q 'recorder_status=Recording' "${out}/desktop.log" && break; sleep 1; done grep -q 'recorder_status=Recording' "${out}/desktop.log" gdbus call --session --dest "${item}" --object-path /StatusNotifierItem --method org.freedesktop.DBus.Properties.Get org.kde.StatusNotifierItem ToolTip > "${out}/recording-tooltip.txt" grep -q Recording "${out}/recording-tooltip.txt" swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null wait_windows 0 before="$(find "${XDG_DATA_HOME}/clipbin/sessions" -name '*.mkv' | wc -l)" sleep 5 after="$(find "${XDG_DATA_HOME}/clipbin/sessions" -name '*.mkv' | wc -l)" (( after > before )) "${repo}/target/debug/clipbin-desktop" > "${out}/second-launch.log" 2>&1 wait_windows 1 swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null wait_windows 0 menu 1 wait_windows 1 # Repeated reopen must release each UI's threads and allocation state. measure() { python3 - "${app}" "$1" <<'PYMETRICS' import pathlib,sys status=dict(line.split(':',1) for line in pathlib.Path(f'/proc/{sys.argv[1]}/status').read_text().splitlines() if ':' in line) print(f"{sys.argv[2]}\t{status['Threads'].strip()}\t{status['VmRSS'].split()[0]}") PYMETRICS } measure 0 > "${out}/reopen-metrics.tsv" for cycle in $(seq 5); do swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null wait_windows 0 sleep 1 menu 1 wait_windows 1 sleep 1 measure "${cycle}" >> "${out}/reopen-metrics.tsv" done cat "${out}/reopen-metrics.tsv" python3 - "${out}/reopen-metrics.tsv" <<'PYBOUNDS' import pathlib,sys rows=[tuple(map(int,line.split())) for line in pathlib.Path(sys.argv[1]).read_text().splitlines()] assert max(r[1] for r in rows[1:]) <= rows[0][1]+4, rows # Allow the first reopen to warm caches; later windows must stay bounded. rss=[r[2] for r in rows[1:]] assert max(rss)-min(rss) <= 32*1024 and rows[-1][2] <= rows[0][2]+64*1024, rows print('PASS five close/reopen cycles have bounded thread count and RSS') PYBOUNDS # Audio delivery loss must be visible in the application and real tray tooltip. kill "$(cat "${CLIPBIN_RIG_DIR}/pulse.pid")" for _ in $(seq 15); do grep -q 'recorder_status=Degraded' "${out}/desktop.log" && break; sleep 1; done grep -q 'recorder_status=Degraded' "${out}/desktop.log" sleep .3 gdbus call --session --dest "${item}" --object-path /StatusNotifierItem --method org.freedesktop.DBus.Properties.Get org.kde.StatusNotifierItem ToolTip > "${out}/audio-lost-tooltip.txt" grep -q 'audio capture lost' "${out}/audio-lost-tooltip.txt" grim "${out}/audio-lost.png" echo 'PASS audio loss is degraded in the UI and tray while video continues' # Quit through D-Bus menu while a game is still alive. menu 4 wait_exit python3 - "${XDG_DATA_HOME}" <<'PY' import pathlib,json,sys files=list(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json')) assert files and all(not json.loads(p.read_text())['session']['active'] for p in files) assert all(s['complete'] for p in files for s in json.loads(p.read_text())['segments']) PY kill "${game}"; wait "${game}" 2>/dev/null || true; game= echo 'PASS close keeps segments growing, second launch reopens, tray Open reopens, tray Quit finalises' # Losing the watcher must reveal the UI; closing then quits cleanly. "${repo}/target/debug/clipbin-desktop" --background > "${out}/host-loss.log" 2>&1 & app=$! sleep 2 wait_windows 0 kill "${host}"; wait "${host}" 2>/dev/null || true; host= wait_windows 1 swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null wait_exit # Reconcile an externally disabled saved setting, removing the existing entry. python3 - "${XDG_CONFIG_HOME}/clipbin/settings.json" <<'PYSETTINGS' import json,pathlib,sys p=pathlib.Path(sys.argv[1]); settings=json.loads(p.read_text()); settings['start_on_login']=False; p.write_text(json.dumps(settings)) PYSETTINGS # With no watcher at startup --background must open a reachable window. "${repo}/target/debug/clipbin-desktop" --background > "${out}/no-tray.log" 2>&1 & app=$! wait_windows 1 grep -q 'Tray unavailable' "${out}/no-tray.log" [[ ! -f "${XDG_CONFIG_HOME}/autostart/clipbin.desktop" ]] swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null wait_exit echo 'PASS watcher loss reveals UI; no-tray background falls back to close=Quit' INNER