#!/usr/bin/env bash # End-to-end desktop/Steam-process/session test in the private Wayland rig. set -euo pipefail # Check before building or starting the rig; missing tools must not look like # a status that has simply not appeared yet. for tool in bash cargo grep python3 ffprobe grim wtype swaymsg dirname mkdir sleep cat seq \ env dbus-run-session mktemp chmod rm basename head dbus-update-activation-environment \ pipewire wireplumber pipewire-pulse pactl sway Xwayland ffplay fusermount3; do if ! command -v "${tool}" > /dev/null; then echo "FAIL required tool '${tool}' is missing from PATH" >&2 exit 127 fi done for tool in /usr/lib/xdg-desktop-portal /usr/lib/xdg-desktop-portal-wlr; do if [[ ! -x "${tool}" ]]; then echo "FAIL required tool '${tool}' is missing or not executable" >&2 exit 127 fi done repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" out="${1:-${TMPDIR:-/tmp}/clipbin-session-validation}" mkdir -p "${out}" cargo build --locked -p clipbin-desktop --manifest-path "${repo}/Cargo.toml" export CLIPBIN_SESSION_REPO="${repo}" CLIPBIN_SESSION_OUT="${out}" "${repo}/scripts/rig/headless.sh" bash <<'INNER' set -euo pipefail # grep returns 1 for no matches; every other error must fail immediately. wait_log() { local pattern="$1" log="$2" minimum="$3" attempts="$4" count status attempt for ((attempt = 0; attempt < attempts; attempt++)); do if count=$(grep -c "${pattern}" "${log}"); then if ((count >= minimum)); then return 0; fi else status=$? if [[ ${status} != 1 ]]; then echo "FAIL could not check '${pattern}' in ${log} (grep exited ${status})" >&2 exit 1 fi fi if ! kill -0 "${app}" 2>/dev/null; then echo "FAIL desktop exited while waiting for '${pattern}'; log: ${log}" >&2 exit 1 fi sleep 1 done echo "FAIL timed out waiting for ${minimum} '${pattern}' entries; log: ${log}" >&2 exit 1 } out="${CLIPBIN_SESSION_OUT}" portal_config="${XDG_CONFIG_HOME}" export XDG_DATA_HOME="${out}/data" XDG_CONFIG_HOME="${out}/config" mkdir -p "${XDG_DATA_HOME}" "${XDG_CONFIG_HOME}" "${CLIPBIN_SESSION_REPO}/target/debug/clipbin-desktop" > "${out}/desktop.log" 2>&1 & app=$! trap 'kill "${app}" "${game:-}" "${portal:-}" 2>/dev/null || true' EXIT sleep 3 swaymsg '[title="clipbin-test-game"] fullscreen disable' > /dev/null || true swaymsg '[app_id="dev.clipbin.Clipbin"] floating enable, resize set 1360 860, move position center, focus' > /dev/null bash -c 'exec -a reaper python3 -c "import time; time.sleep(120)" SteamLaunch AppId=480 --' & game=$! python3 - "${game}" <<'PY' import sys,time for _ in range(20): argv = open(f'/proc/{sys.argv[1]}/cmdline', 'rb').read().split(b'\0') if argv[0] == b'reaper': break time.sleep(.1) argv = open(f'/proc/{sys.argv[1]}/cmdline', 'rb').read().split(b'\0') assert argv[0] == b'reaper' and b'SteamLaunch' in argv and b'AppId=480' in argv, argv PY wait_log 'recorder_status=Recording' "${out}/desktop.log" 1 30 swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null sleep 0.5 grim "${out}/recording.png" if [[ "${CLIPBIN_VALIDATE_RECOVERY:-0}" == 1 ]]; then kill "$(cat "${CLIPBIN_RIG_DIR}/xdpw.pid")" wait_log 'recorder_status=Failed' "${out}/desktop.log" 1 15 grim "${out}/capture-failed.png" XDG_CONFIG_HOME="${portal_config}" /usr/lib/xdg-desktop-portal-wlr > "${out}/recovered-portal.log" 2>&1 & portal=$! wait_log 'recorder_status=Recording' "${out}/desktop.log" 2 30 python3 - "${XDG_DATA_HOME}" <<'PY' import pathlib,json,sys p=max(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json'), key=lambda p:int(p.parent.name)) m=json.loads(p.read_text()) assert any(s['path'].startswith('run-002/') for s in m['segments']) assert len([s for s in m['session']['spans'] if s['kind']=='Missing']) >= 2 print('PASS capture recovery resumed in the same session with a Missing gap') PY fi # Closing the sole window must leave the process and footage running. swaymsg '[app_id="dev.clipbin.Clipbin"] kill' > /dev/null kill -0 "${app}" sleep 3 kill -0 "${app}" kill "${game}" wait "${game}" 2>/dev/null || true finalised=0 for _ in $(seq 20); do status=0 python3 - "${XDG_DATA_HOME}" <<'PY' || status=$? import pathlib, json, sys files = list(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json')) if not files: sys.exit(2) path = max(files, key=lambda p: int(p.parent.name)) m = json.loads(path.read_text()) if m['session']['active']: sys.exit(2) assert m['app_id'] == 480 assert m['segments'] and all(s['complete'] for s in m['segments']) assert all((path.parent / s['path']).stat().st_size > 0 for s in m['segments']) assert any(s['kind'] == 'Footage' for s in m['session']['spans']) print('PASS session finalised with footage') PY case "${status}" in 0) finalised=1; break ;; 2) sleep 1 ;; *) echo "FAIL session finalisation check exited ${status}" >&2; exit 1 ;; esac done if [[ ${finalised} != 1 ]]; then echo "FAIL timed out waiting for session finalisation; log: ${out}/desktop.log" >&2 exit 1 fi python3 - "${XDG_DATA_HOME}" <<'PY' import pathlib,json,subprocess,sys path=max(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json'), key=lambda p:int(p.parent.name)) m=json.loads(path.read_text()) ns=lambda d:d['secs']*1_000_000_000+d['nanos'] runs={} for segment in m['segments']: run=segment['path'].split('/')[0] base,packets=runs.get(run,(ns(segment['start']),0)) assert segment['dropped_frames']==0, 'Dropped frames make count-derived offsets uncertain' expected=base+packets*1_000_000_000//segment['frames_per_second'] assert ns(segment['start'])==expected, (segment['path'],ns(segment['start']),expected) if not segment['available']: assert ns(segment['end'])==ns(segment['start']) runs[run]=(base,packets) continue result=json.loads(subprocess.check_output(['ffprobe','-v','error','-select_streams','v:0','-count_packets','-show_entries','stream=nb_read_packets','-of','json',str(path.parent/segment['path'])])) count=int(result['streams'][0]['nb_read_packets']) assert count>0 runs[run]=(base,packets+count) print('PASS segment offsets exactly match cumulative earlier video packet counts') PY 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) PY for _ in $(seq 10); do if ! kill -0 "${app}" 2>/dev/null; then break; fi sleep 1 done if kill -0 "${app}" 2>/dev/null; then echo 'FAIL closed app kept discovering after game exit' >&2; exit 1; fi wait "${app}" kill "$(cat "${CLIPBIN_RIG_DIR}/game.pid")" # Reopen to prove the library loads from disk, then Quit cleanly with Ctrl+Q. "${CLIPBIN_SESSION_REPO}/target/debug/clipbin-desktop" > "${out}/reopened.log" 2>&1 & app=$! sleep 4 swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null sleep 0.5 grim "${out}/library.png" bash -c 'exec -a reaper python3 -c "import time; time.sleep(120)" SteamLaunch AppId=480 --' & game=$! wait_log 'recorder_status=Recording' "${out}/reopened.log" 1 30 swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null wtype -M ctrl -k q -m ctrl for _ in $(seq 20); do if ! kill -0 "${app}" 2>/dev/null; then break; fi sleep 1 done if kill -0 "${app}" 2>/dev/null; then echo 'FAIL explicit Quit did not exit' >&2; exit 1; fi wait "${app}" python3 - "${XDG_DATA_HOME}" <<'PY' import pathlib,json,sys files=list(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json')) assert len(files) >= 2 assert all(not json.loads(p.read_text())['session']['active'] for p in files) PY echo "PASS automatic recording, window close, game exit, library reload and Quit" INNER