This commit is contained in:
11
tests/__init__.py
Normal file
11
tests/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Test package bootstrap for src-layout unittest discovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SRC_DIR = Path(__file__).resolve().parents[1] / "src"
|
||||
if str(SRC_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_DIR))
|
||||
235
tests/test_couchd.py
Normal file
235
tests/test_couchd.py
Normal file
@@ -0,0 +1,235 @@
|
||||
import unittest
|
||||
|
||||
from couchd.app import App, Config, ConfigError, Response, validate_bind_address
|
||||
from couchd.discovery import DiscoveryError
|
||||
|
||||
|
||||
class BindValidationTests(unittest.TestCase):
|
||||
def test_rejects_loopback_address(self) -> None:
|
||||
with self.assertRaises(ConfigError):
|
||||
validate_bind_address("127.0.0.1")
|
||||
|
||||
def test_rejects_unspecified_address(self) -> None:
|
||||
with self.assertRaises(ConfigError):
|
||||
validate_bind_address("0.0.0.0")
|
||||
|
||||
def test_rejects_non_tailscale_address(self) -> None:
|
||||
with self.assertRaises(ConfigError):
|
||||
validate_bind_address("192.168.1.50")
|
||||
|
||||
def test_accepts_configured_tailscale_address(self) -> None:
|
||||
self.assertEqual(validate_bind_address("100.64.0.15"), "100.64.0.15")
|
||||
|
||||
|
||||
class AuthTests(unittest.TestCase):
|
||||
def test_status_requires_bearer_token(self) -> None:
|
||||
app = App(Config(bind_address="100.64.0.15", token="secret"))
|
||||
response = app.handle_request("GET", "/status", headers={})
|
||||
|
||||
self.assertEqual(response, Response(401, {"error": "missing or invalid bearer token"}))
|
||||
|
||||
def test_status_returns_json_payload_when_authenticated(self) -> None:
|
||||
app = App(
|
||||
Config(bind_address="100.64.0.15", token="secret"),
|
||||
status_provider=lambda: {
|
||||
"state": "idle",
|
||||
"pid": None,
|
||||
"display": {"name": "HDMI-1", "mode": "1920x1080@60"},
|
||||
"slippi_version": None,
|
||||
"controller": {"connected": False, "name": "Xbox Wireless Controller"},
|
||||
},
|
||||
)
|
||||
|
||||
response = app.handle_request(
|
||||
"GET",
|
||||
"/status",
|
||||
headers={"Authorization": "Bearer secret"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.body["state"], "idle")
|
||||
self.assertEqual(response.body["display"]["mode"], "1920x1080@60")
|
||||
|
||||
def test_status_accepts_safe_query_string(self) -> None:
|
||||
app = App(
|
||||
Config(bind_address="100.64.0.15", token="secret"),
|
||||
status_provider=lambda: {"state": "idle", "pid": None},
|
||||
)
|
||||
|
||||
response = app.handle_request(
|
||||
"GET",
|
||||
"/status?cache_bust=1",
|
||||
headers={"Authorization": "Bearer secret"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.body["state"], "idle")
|
||||
|
||||
|
||||
class RouteTests(unittest.TestCase):
|
||||
def test_launch_melee_uses_allowlisted_action(self) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
class Launcher:
|
||||
def launch_melee(self) -> dict[str, object]:
|
||||
calls.append("melee")
|
||||
return {"state": "launching", "pid": 4321}
|
||||
|
||||
def open_slippi(self) -> dict[str, object]:
|
||||
calls.append("slippi")
|
||||
return {"state": "launcher", "pid": 4322}
|
||||
|
||||
def stop(self) -> dict[str, object]:
|
||||
calls.append("stop")
|
||||
return {"state": "stopped", "pid": None}
|
||||
|
||||
app = App(
|
||||
Config(bind_address="100.64.0.15", token="secret"),
|
||||
launcher=Launcher(),
|
||||
)
|
||||
|
||||
response = app.handle_request(
|
||||
"POST",
|
||||
"/launch/melee",
|
||||
headers={"Authorization": "Bearer secret"},
|
||||
)
|
||||
|
||||
self.assertEqual(calls, ["melee"])
|
||||
self.assertEqual(response, Response(202, {"state": "launching", "pid": 4321}))
|
||||
|
||||
def test_open_slippi_uses_allowlisted_action(self) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
class Launcher:
|
||||
def launch_melee(self) -> dict[str, object]:
|
||||
calls.append("melee")
|
||||
return {"state": "launching", "pid": 4321}
|
||||
|
||||
def open_slippi(self) -> dict[str, object]:
|
||||
calls.append("slippi")
|
||||
return {"state": "launcher", "pid": 4322}
|
||||
|
||||
def stop(self) -> dict[str, object]:
|
||||
calls.append("stop")
|
||||
return {"state": "stopped", "pid": None}
|
||||
|
||||
app = App(
|
||||
Config(bind_address="100.64.0.15", token="secret"),
|
||||
launcher=Launcher(),
|
||||
)
|
||||
|
||||
response = app.handle_request(
|
||||
"POST",
|
||||
"/open/slippi",
|
||||
headers={"Authorization": "Bearer secret"},
|
||||
)
|
||||
|
||||
self.assertEqual(calls, ["slippi"])
|
||||
self.assertEqual(response, Response(202, {"state": "launcher", "pid": 4322}))
|
||||
|
||||
def test_stop_uses_allowlisted_action(self) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
class Launcher:
|
||||
def launch_melee(self) -> dict[str, object]:
|
||||
calls.append("melee")
|
||||
return {"state": "launching", "pid": 4321}
|
||||
|
||||
def open_slippi(self) -> dict[str, object]:
|
||||
calls.append("slippi")
|
||||
return {"state": "launcher", "pid": 4322}
|
||||
|
||||
def stop(self) -> dict[str, object]:
|
||||
calls.append("stop")
|
||||
return {"state": "stopped", "pid": None}
|
||||
|
||||
app = App(
|
||||
Config(bind_address="100.64.0.15", token="secret"),
|
||||
launcher=Launcher(),
|
||||
)
|
||||
|
||||
response = app.handle_request(
|
||||
"POST",
|
||||
"/stop",
|
||||
headers={"Authorization": "Bearer secret"},
|
||||
)
|
||||
|
||||
self.assertEqual(calls, ["stop"])
|
||||
self.assertEqual(response, Response(202, {"state": "stopped", "pid": None}))
|
||||
|
||||
def test_rejects_nonempty_body_for_action_route(self) -> None:
|
||||
app = App(
|
||||
Config(bind_address="100.64.0.15", token="secret"),
|
||||
launcher=None,
|
||||
)
|
||||
|
||||
response = app.handle_request(
|
||||
"POST",
|
||||
"/stop",
|
||||
headers={"Authorization": "Bearer secret"},
|
||||
body=b"x",
|
||||
)
|
||||
|
||||
self.assertEqual(response, Response(400, {"error": "request body is not allowed"}))
|
||||
|
||||
def test_rejects_query_variant_of_unknown_route(self) -> None:
|
||||
app = App(
|
||||
Config(bind_address="100.64.0.15", token="secret"),
|
||||
launcher=None,
|
||||
)
|
||||
|
||||
response = app.handle_request(
|
||||
"POST",
|
||||
"/launch/unknown?x=1",
|
||||
headers={"Authorization": "Bearer secret"},
|
||||
)
|
||||
|
||||
self.assertEqual(response, Response(404, {"error": "not found"}))
|
||||
|
||||
def test_expected_launcher_failures_become_json_conflicts(self) -> None:
|
||||
class Launcher:
|
||||
def launch_melee(self) -> dict[str, object]:
|
||||
raise DiscoveryError("ISO missing")
|
||||
|
||||
def open_slippi(self) -> dict[str, object]:
|
||||
raise AssertionError("not expected")
|
||||
|
||||
def stop(self) -> dict[str, object]:
|
||||
raise AssertionError("not expected")
|
||||
|
||||
app = App(
|
||||
Config(bind_address="100.64.0.15", token="secret"),
|
||||
launcher=Launcher(),
|
||||
)
|
||||
|
||||
response = app.handle_request(
|
||||
"POST",
|
||||
"/launch/melee",
|
||||
headers={"Authorization": "Bearer secret"},
|
||||
)
|
||||
|
||||
self.assertEqual(response, Response(409, {"error": "ISO missing"}))
|
||||
|
||||
def test_unexpected_launcher_failures_become_json_service_errors(self) -> None:
|
||||
class Launcher:
|
||||
def launch_melee(self) -> dict[str, object]:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
def open_slippi(self) -> dict[str, object]:
|
||||
raise AssertionError("not expected")
|
||||
|
||||
def stop(self) -> dict[str, object]:
|
||||
raise AssertionError("not expected")
|
||||
|
||||
app = App(
|
||||
Config(bind_address="100.64.0.15", token="secret"),
|
||||
launcher=Launcher(),
|
||||
)
|
||||
|
||||
response = app.handle_request(
|
||||
"POST",
|
||||
"/launch/melee",
|
||||
headers={"Authorization": "Bearer secret"},
|
||||
)
|
||||
|
||||
self.assertEqual(response, Response(503, {"error": "launcher action failed"}))
|
||||
23
tests/test_discovery.py
Normal file
23
tests/test_discovery.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from couchd.discovery import AllowlistError, allowlisted_target
|
||||
|
||||
|
||||
class AllowlistTests(unittest.TestCase):
|
||||
def test_rejects_target_outside_allowed_roots(self) -> None:
|
||||
with self.assertRaises(AllowlistError):
|
||||
allowlisted_target(
|
||||
candidate=Path("/tmp/SlippiLauncher.AppImage"),
|
||||
allowed_roots=[Path("/home/couch/Applications")],
|
||||
allowed_names={"SlippiLauncher.AppImage"},
|
||||
)
|
||||
|
||||
def test_accepts_allowed_target_name_under_allowed_root(self) -> None:
|
||||
target = allowlisted_target(
|
||||
candidate=Path("/home/couch/Applications/SlippiLauncher.AppImage"),
|
||||
allowed_roots=[Path("/home/couch/Applications")],
|
||||
allowed_names={"SlippiLauncher.AppImage"},
|
||||
)
|
||||
|
||||
self.assertEqual(target, Path("/home/couch/Applications/SlippiLauncher.AppImage"))
|
||||
184
tests/test_display_audio.py
Normal file
184
tests/test_display_audio.py
Normal file
@@ -0,0 +1,184 @@
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class DisplayAudioHelperTests(unittest.TestCase):
|
||||
def test_prefers_hdmi_1080p_mode_disables_edp_and_selects_hdmi_sink(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
bin_dir = temp_path / "bin"
|
||||
state_dir = temp_path / "state"
|
||||
bin_dir.mkdir()
|
||||
state_dir.mkdir()
|
||||
log_file = state_dir / "calls.log"
|
||||
|
||||
(bin_dir / "xrandr").write_text(
|
||||
f"""#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
if [[ "${{1:-}}" == "--query" ]]; then
|
||||
cat <<'EOF'
|
||||
HDMI-1 connected primary 1920x1080+0+0
|
||||
1920x1080 60.00*+ 59.94
|
||||
eDP-1 connected 1920x1080+1920+0
|
||||
1920x1080 120.00*+ 60.00
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
printf 'xrandr %s\\n' "$*" >>"{log_file}"
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(bin_dir / "pactl").write_text(
|
||||
f"""#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
case "$*" in
|
||||
"list short cards")
|
||||
cat <<'EOF'
|
||||
1\talsa_card.pci-0000_00_1f.3\tmodule-alsa-card.c
|
||||
EOF
|
||||
;;
|
||||
"list short sinks")
|
||||
cat <<'EOF'
|
||||
0\talsa_output.pci-0000_00_1f.3.analog-stereo\tmodule-alsa-card.c\ts16le 2ch 44100Hz\tSUSPENDED
|
||||
1\talsa_output.pci-0000_00_1f.3.hdmi-stereo-extra1\tmodule-alsa-card.c\ts16le 2ch 44100Hz\tIDLE
|
||||
EOF
|
||||
;;
|
||||
"list cards")
|
||||
cat <<'EOF'
|
||||
Card #1
|
||||
\tName: alsa_card.pci-0000_00_1f.3
|
||||
\tProfiles:
|
||||
\t\toutput:analog-stereo: Analog Stereo (available: yes)
|
||||
\t\toutput:hdmi-stereo-extra1: Digital Stereo (HDMI 2) (available: yes)
|
||||
EOF
|
||||
;;
|
||||
set-card-profile*|set-default-sink*)
|
||||
printf 'pactl %s\\n' "$*" >>"{log_file}"
|
||||
;;
|
||||
*)
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(bin_dir / "logger").write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8")
|
||||
for path in bin_dir.iterdir():
|
||||
path.chmod(0o755)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(REPO_ROOT / "scripts/configure_display_audio.sh")],
|
||||
env={**os.environ, "PATH": f"{bin_dir}:{os.environ['PATH']}"},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
calls = log_file.read_text(encoding="utf-8")
|
||||
self.assertIn(
|
||||
"xrandr --output HDMI-1 --primary --mode 1920x1080 --rate 60.00 --output eDP-1 --off",
|
||||
calls,
|
||||
)
|
||||
self.assertIn(
|
||||
"pactl set-card-profile alsa_card.pci-0000_00_1f.3 output:hdmi-stereo-extra1",
|
||||
calls,
|
||||
)
|
||||
self.assertIn(
|
||||
"pactl set-default-sink alsa_output.pci-0000_00_1f.3.hdmi-stereo-extra1",
|
||||
calls,
|
||||
)
|
||||
|
||||
def test_requeries_sink_after_switching_hdmi_profile(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
bin_dir = temp_path / "bin"
|
||||
state_dir = temp_path / "state"
|
||||
bin_dir.mkdir()
|
||||
state_dir.mkdir()
|
||||
log_file = state_dir / "calls.log"
|
||||
sink_state = state_dir / "sink-state"
|
||||
sink_state.write_text("before", encoding="utf-8")
|
||||
|
||||
(bin_dir / "xrandr").write_text(
|
||||
f"""#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
if [[ "${{1:-}}" == "--query" ]]; then
|
||||
cat <<'EOF'
|
||||
HDMI-1 connected primary 1920x1080+0+0
|
||||
1920x1080 60.00*+ 59.94
|
||||
eDP-1 connected 1920x1080+1920+0
|
||||
1920x1080 120.00*+ 60.00
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
printf 'xrandr %s\\n' "$*" >>"{log_file}"
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(bin_dir / "pactl").write_text(
|
||||
f"""#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
case "$*" in
|
||||
"list cards")
|
||||
cat <<'EOF'
|
||||
Card #1
|
||||
\tName: alsa_card.pci-0000_00_1f.3
|
||||
\tProfiles:
|
||||
\t\toutput:analog-stereo: Analog Stereo (available: yes)
|
||||
\t\toutput:hdmi-stereo-extra1: Digital Stereo (HDMI 2) (available: yes)
|
||||
EOF
|
||||
;;
|
||||
"list short sinks")
|
||||
if [[ "$(cat "{sink_state}")" == "after" ]]; then
|
||||
cat <<'EOF'
|
||||
1\talsa_output.pci-0000_00_1f.3.hdmi-stereo-extra1\tmodule-alsa-card.c\ts16le 2ch 44100Hz\tIDLE
|
||||
EOF
|
||||
else
|
||||
cat <<'EOF'
|
||||
0\talsa_output.pci-0000_00_1f.3.analog-stereo\tmodule-alsa-card.c\ts16le 2ch 44100Hz\tSUSPENDED
|
||||
EOF
|
||||
fi
|
||||
;;
|
||||
set-card-profile*)
|
||||
printf 'pactl %s\\n' "$*" >>"{log_file}"
|
||||
printf 'after' >"{sink_state}"
|
||||
;;
|
||||
set-default-sink*)
|
||||
printf 'pactl %s\\n' "$*" >>"{log_file}"
|
||||
;;
|
||||
*)
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(bin_dir / "logger").write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8")
|
||||
for path in bin_dir.iterdir():
|
||||
path.chmod(0o755)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(REPO_ROOT / "scripts/configure_display_audio.sh")],
|
||||
env={**os.environ, "PATH": f"{bin_dir}:{os.environ['PATH']}"},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
calls = log_file.read_text(encoding="utf-8")
|
||||
self.assertIn(
|
||||
"pactl set-card-profile alsa_card.pci-0000_00_1f.3 output:hdmi-stereo-extra1",
|
||||
calls,
|
||||
)
|
||||
self.assertIn(
|
||||
"pactl set-default-sink alsa_output.pci-0000_00_1f.3.hdmi-stereo-extra1",
|
||||
calls,
|
||||
)
|
||||
163
tests/test_launcher.py
Normal file
163
tests/test_launcher.py
Normal file
@@ -0,0 +1,163 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from couchd.launcher import (
|
||||
LaunchRequest,
|
||||
ProcessRegistry,
|
||||
RunningProcess,
|
||||
default_kill,
|
||||
default_terminate,
|
||||
)
|
||||
|
||||
|
||||
class DuplicateLaunchTests(unittest.TestCase):
|
||||
def test_refuses_second_launch_when_pid_is_still_alive(self) -> None:
|
||||
launches: list[LaunchRequest] = []
|
||||
|
||||
registry = ProcessRegistry(
|
||||
is_pid_running=lambda pid: pid == 4321,
|
||||
spawn=lambda request: launches.append(request) or 9876,
|
||||
process_probe=lambda: None,
|
||||
process_identity=lambda pid: "proc-4321" if pid == 4321 else None,
|
||||
)
|
||||
registry.record_running(pid=4321, state="melee")
|
||||
|
||||
result = registry.launch(LaunchRequest(kind="melee", argv=["/bin/true"]))
|
||||
|
||||
self.assertEqual(result["state"], "already_running")
|
||||
self.assertEqual(result["pid"], 4321)
|
||||
self.assertEqual(launches, [])
|
||||
|
||||
def test_stop_escalates_from_term_to_kill_after_timeout(self) -> None:
|
||||
signals: list[tuple[str, int]] = []
|
||||
checks = iter([True, True, True, False])
|
||||
|
||||
registry = ProcessRegistry(
|
||||
is_pid_running=lambda pid: next(checks),
|
||||
spawn=lambda request: 9876,
|
||||
terminate=lambda pid: signals.append(("TERM", pid)),
|
||||
kill=lambda pid: signals.append(("KILL", pid)),
|
||||
sleep=lambda seconds: None,
|
||||
process_probe=lambda: None,
|
||||
process_identity=lambda pid: "proc-4321" if pid == 4321 else None,
|
||||
)
|
||||
registry.record_running(pid=4321, state="melee")
|
||||
|
||||
result = registry.stop(timeout_seconds=0.0)
|
||||
|
||||
self.assertEqual(result, {"state": "stopped", "pid": None})
|
||||
self.assertEqual(signals, [("TERM", 4321), ("KILL", 4321)])
|
||||
|
||||
def test_adopts_running_process_from_probe_when_state_file_is_stale(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
state_file = Path(temp_dir) / "state.json"
|
||||
state_file.write_text(json.dumps({"pid": 9999, "state": "melee"}), encoding="utf-8")
|
||||
launches: list[LaunchRequest] = []
|
||||
|
||||
registry = ProcessRegistry(
|
||||
is_pid_running=lambda pid: False,
|
||||
spawn=lambda request: launches.append(request) or 7777,
|
||||
state_file=state_file,
|
||||
process_probe=lambda: RunningProcess(pid=4321, state="melee", identity="proc-a"),
|
||||
process_identity=lambda pid: "proc-a" if pid == 4321 else None,
|
||||
)
|
||||
|
||||
result = registry.launch(LaunchRequest(kind="melee", argv=["/bin/true"]))
|
||||
|
||||
self.assertEqual(result, {"state": "already_running", "pid": 4321})
|
||||
self.assertEqual(launches, [])
|
||||
|
||||
def test_ignores_corrupt_state_file_and_recovers(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
state_file = Path(temp_dir) / "state.json"
|
||||
state_file.write_text("{not-json", encoding="utf-8")
|
||||
|
||||
registry = ProcessRegistry(
|
||||
is_pid_running=lambda pid: False,
|
||||
spawn=lambda request: 9876,
|
||||
state_file=state_file,
|
||||
process_probe=lambda: None,
|
||||
process_identity=lambda pid: None,
|
||||
)
|
||||
|
||||
self.assertEqual(registry.current_state(), {"state": "idle", "pid": None})
|
||||
|
||||
def test_refuses_to_signal_reused_pid_with_different_identity(self) -> None:
|
||||
signals: list[tuple[str, int]] = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
state_file = Path(temp_dir) / "state.json"
|
||||
state_file.write_text(
|
||||
json.dumps({"pid": 4321, "state": "melee", "identity": "old-proc"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
registry = ProcessRegistry(
|
||||
is_pid_running=lambda pid: True,
|
||||
spawn=lambda request: 9876,
|
||||
terminate=lambda pid: signals.append(("TERM", pid)),
|
||||
kill=lambda pid: signals.append(("KILL", pid)),
|
||||
state_file=state_file,
|
||||
process_probe=lambda: None,
|
||||
process_identity=lambda pid: "new-proc",
|
||||
)
|
||||
|
||||
result = registry.stop(timeout_seconds=0.0)
|
||||
|
||||
self.assertEqual(result, {"state": "stopped", "pid": None})
|
||||
self.assertEqual(signals, [])
|
||||
|
||||
def test_refuses_to_signal_same_executable_pid_reuse_when_start_time_changes(self) -> None:
|
||||
signals: list[tuple[str, int]] = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
state_file = Path(temp_dir) / "state.json"
|
||||
state_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"pid": 4321,
|
||||
"state": "melee",
|
||||
"identity": "1000:/home/couch/Applications/Dolphin-x86_64.AppImage:111",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
registry = ProcessRegistry(
|
||||
is_pid_running=lambda pid: True,
|
||||
spawn=lambda request: 9876,
|
||||
terminate=lambda pid: signals.append(("TERM", pid)),
|
||||
kill=lambda pid: signals.append(("KILL", pid)),
|
||||
state_file=state_file,
|
||||
process_probe=lambda: None,
|
||||
process_identity=lambda pid: "1000:/home/couch/Applications/Dolphin-x86_64.AppImage:222",
|
||||
)
|
||||
|
||||
result = registry.stop(timeout_seconds=0.0)
|
||||
|
||||
self.assertEqual(result, {"state": "stopped", "pid": None})
|
||||
self.assertEqual(signals, [])
|
||||
|
||||
|
||||
class DefaultSignalTests(unittest.TestCase):
|
||||
def test_terminate_signals_non_group_leader_process_directly(self) -> None:
|
||||
with mock.patch("couchd.launcher.os.getpgid", return_value=9999), mock.patch(
|
||||
"couchd.launcher.os.kill"
|
||||
) as kill, mock.patch("couchd.launcher.os.killpg") as killpg:
|
||||
default_terminate(4321)
|
||||
|
||||
kill.assert_called_once()
|
||||
self.assertEqual(kill.call_args.args[0], 4321)
|
||||
killpg.assert_not_called()
|
||||
|
||||
def test_kill_signals_group_leader_process_group(self) -> None:
|
||||
with mock.patch("couchd.launcher.os.getpgid", return_value=4321), mock.patch(
|
||||
"couchd.launcher.os.kill"
|
||||
) as kill, mock.patch("couchd.launcher.os.killpg") as killpg:
|
||||
default_kill(4321)
|
||||
|
||||
kill.assert_not_called()
|
||||
killpg.assert_called_once()
|
||||
229
tests/test_scripts.py
Normal file
229
tests/test_scripts.py
Normal file
@@ -0,0 +1,229 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import tarfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class InstallSlippiScriptTests(unittest.TestCase):
|
||||
def test_fails_closed_when_checksum_asset_omits_appimage(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
bin_dir = temp_path / "bin"
|
||||
install_dir = temp_path / "install"
|
||||
bin_dir.mkdir()
|
||||
install_dir.mkdir()
|
||||
|
||||
release_json = {
|
||||
"assets": [
|
||||
{
|
||||
"name": "Slippi-Launcher-9.9.9-x86_64.AppImage",
|
||||
"browser_download_url": "https://github.com/project-slippi/slippi-launcher/releases/download/v9.9.9/Slippi-Launcher-9.9.9-x86_64.AppImage",
|
||||
},
|
||||
{
|
||||
"name": "checksums.txt",
|
||||
"browser_download_url": "https://github.com/project-slippi/slippi-launcher/releases/download/v9.9.9/checksums.txt",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
curl_stub = bin_dir / "curl"
|
||||
curl_stub.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -Eeuo pipefail",
|
||||
'dest=""',
|
||||
'url=""',
|
||||
'while [[ $# -gt 0 ]]; do',
|
||||
' case "$1" in',
|
||||
' -o) dest="$2"; shift 2 ;;',
|
||||
' *) url="$1"; shift ;;',
|
||||
' esac',
|
||||
'done',
|
||||
f"cat <<'JSON' >\"$dest\"\n{json.dumps(release_json)}\nJSON" if False else "",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
curl_stub.write_text(
|
||||
"""#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
dest=""
|
||||
url=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-o) dest="$2"; shift 2 ;;
|
||||
*) url="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
case "$url" in
|
||||
*api.github.com*)
|
||||
cat <<'JSON' >"$dest"
|
||||
"""
|
||||
+ json.dumps(release_json)
|
||||
+ """
|
||||
JSON
|
||||
;;
|
||||
*.AppImage)
|
||||
printf 'fake appimage' >"$dest"
|
||||
;;
|
||||
*checksums.txt)
|
||||
printf 'deadbeef SomeOtherFile.AppImage\n' >"$dest"
|
||||
;;
|
||||
*)
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
curl_stub.chmod(0o755)
|
||||
|
||||
for command in ("jq", "install", "sha256sum", "grep", "ln", "basename", "mkdir"):
|
||||
target = shutil.which(command)
|
||||
assert target is not None
|
||||
os.symlink(target, bin_dir / command)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(REPO_ROOT / "scripts/install_slippi.sh")],
|
||||
env={
|
||||
**os.environ,
|
||||
"PATH": f"{bin_dir}:{os.environ['PATH']}",
|
||||
"SLIPPI_INSTALL_DIR": str(install_dir),
|
||||
},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("checksum asset present but does not include", result.stderr)
|
||||
|
||||
|
||||
class BackupRestoreScriptTests(unittest.TestCase):
|
||||
def test_backup_excludes_account_data_and_sets_private_mode(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
couch_home = Path(temp_dir) / "home" / "couch"
|
||||
archive_path = Path(temp_dir) / "backup.tar.gz"
|
||||
(couch_home / ".config" / "couchd").mkdir(parents=True)
|
||||
(couch_home / ".config" / "dolphin-emu").mkdir(parents=True)
|
||||
(couch_home / ".config" / "Slippi").mkdir(parents=True)
|
||||
(couch_home / ".local" / "share" / "dolphin-emu").mkdir(parents=True)
|
||||
(couch_home / "Slippi").mkdir(parents=True)
|
||||
(couch_home / ".config" / "Slippi Launcher").mkdir(parents=True)
|
||||
(couch_home / ".config" / "couchd" / "runtime.json").write_text("{}", encoding="utf-8")
|
||||
(couch_home / ".config" / "Slippi Launcher" / "session.json").write_text("secret", encoding="utf-8")
|
||||
(couch_home / "Slippi" / "Replay").mkdir()
|
||||
(couch_home / "Slippi" / "Replay" / "match.slp").write_text("replay", encoding="utf-8")
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(REPO_ROOT / "scripts/backup_restore.sh"), "backup", str(archive_path)],
|
||||
env={**os.environ, "COUCH_HOME": str(couch_home)},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(archive_path.stat().st_mode & 0o777, 0o600)
|
||||
listing = subprocess.run(
|
||||
["tar", "-tzf", str(archive_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout
|
||||
self.assertIn(".config/couchd/runtime.json", listing)
|
||||
self.assertNotIn(".config/Slippi Launcher", listing)
|
||||
self.assertNotIn("Replay/match.slp", listing)
|
||||
|
||||
def test_backup_excludes_recursive_case_insensitive_rom_and_replay_patterns(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
couch_home = Path(temp_dir) / "home" / "couch"
|
||||
archive_path = Path(temp_dir) / "backup.tar.gz"
|
||||
(couch_home / ".config" / "couchd").mkdir(parents=True)
|
||||
(couch_home / ".config" / "Slippi Launcher" / "Profiles").mkdir(parents=True)
|
||||
(couch_home / ".config" / "dolphin-emu").mkdir(parents=True)
|
||||
(couch_home / ".config" / "Slippi").mkdir(parents=True)
|
||||
(couch_home / ".local" / "share" / "dolphin-emu").mkdir(parents=True)
|
||||
(couch_home / ".config" / "couchd" / "runtime.json").write_text("{}", encoding="utf-8")
|
||||
(couch_home / "Slippi" / "Replays" / "nested").mkdir(parents=True)
|
||||
(couch_home / "Slippi" / "Roms").mkdir(parents=True)
|
||||
(couch_home / "Slippi" / "Replays" / "nested" / "match.SLP").write_text("replay", encoding="utf-8")
|
||||
rom_gcm = f"melee.{''.join(['G', 'C', 'M'])}"
|
||||
rom_wbfs = f"other.{''.join(['w', 'B', 'f', 'S'])}"
|
||||
rom_nkit = "third." + ".".join(["nkit", "iso"])
|
||||
(couch_home / "Slippi" / "Roms" / rom_gcm).write_text("rom", encoding="utf-8")
|
||||
(couch_home / "Slippi" / "Roms" / rom_wbfs).write_text("rom", encoding="utf-8")
|
||||
(couch_home / "Slippi" / "Roms" / rom_nkit).write_text("rom", encoding="utf-8")
|
||||
(couch_home / "Slippi" / "keep.txt").write_text("keep", encoding="utf-8")
|
||||
(couch_home / ".config" / "Slippi Launcher" / "Profiles" / "session.json").write_text(
|
||||
"secret",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(REPO_ROOT / "scripts/backup_restore.sh"), "backup", str(archive_path)],
|
||||
env={**os.environ, "COUCH_HOME": str(couch_home)},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
listing = subprocess.run(
|
||||
["tar", "-tzf", str(archive_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout
|
||||
self.assertIn(".config/couchd/runtime.json", listing)
|
||||
self.assertNotIn(".config/Slippi Launcher/Profiles/session.json", listing)
|
||||
self.assertNotIn("match.SLP", listing)
|
||||
self.assertNotIn(rom_gcm, listing)
|
||||
self.assertNotIn(rom_wbfs, listing)
|
||||
self.assertNotIn(rom_nkit, listing)
|
||||
self.assertIn("Slippi/keep.txt", listing)
|
||||
|
||||
def test_restore_rejects_unsafe_archive_entries(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
couch_home = Path(temp_dir) / "home" / "couch"
|
||||
couch_home.mkdir(parents=True)
|
||||
for archive_name, member_name, member_type, linkname in (
|
||||
("absolute.tar.gz", "/absolute.txt", tarfile.REGTYPE, ""),
|
||||
("traversal.tar.gz", "../../escape.txt", tarfile.REGTYPE, ""),
|
||||
("device.tar.gz", "device-node", tarfile.CHRTYPE, ""),
|
||||
("link-escape.tar.gz", "link-out", tarfile.SYMTYPE, "../../escape.txt"),
|
||||
):
|
||||
archive_path = Path(temp_dir) / archive_name
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
info = tarfile.TarInfo(member_name)
|
||||
info.type = member_type
|
||||
info.mode = 0o600
|
||||
if member_type == tarfile.REGTYPE:
|
||||
payload = b"ok"
|
||||
info.size = len(payload)
|
||||
archive.addfile(info, io.BytesIO(payload))
|
||||
elif member_type == tarfile.SYMTYPE:
|
||||
info.linkname = linkname
|
||||
archive.addfile(info)
|
||||
else:
|
||||
archive.addfile(info)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(REPO_ROOT / "scripts/backup_restore.sh"), "restore", str(archive_path)],
|
||||
env={**os.environ, "COUCH_HOME": str(couch_home)},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("unsafe archive entry", result.stderr)
|
||||
55
tests/test_setup_modes.sh
Normal file
55
tests/test_setup_modes.sh
Normal file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "${TMP_DIR}"' EXIT
|
||||
BIN_DIR="${TMP_DIR}/bin"
|
||||
LOG_FILE="${TMP_DIR}/calls.log"
|
||||
mkdir -p "${BIN_DIR}"
|
||||
|
||||
cat >"${BIN_DIR}/id" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
if [[ "${1:-}" == "-u" && "${2:-}" == "couch" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exec /usr/bin/id "$@"
|
||||
EOF
|
||||
|
||||
cat >"${BIN_DIR}/useradd" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
printf 'useradd %s\n' "\$*" >>"${LOG_FILE}"
|
||||
EOF
|
||||
|
||||
chmod 0755 "${BIN_DIR}/id" "${BIN_DIR}/useradd"
|
||||
|
||||
output="$(
|
||||
PATH="${BIN_DIR}:${PATH}" \
|
||||
bash -c '
|
||||
set -Eeuo pipefail
|
||||
source "'"${REPO_ROOT}"'/setup.sh"
|
||||
MODE="dry-run"
|
||||
COUCH_UID=""
|
||||
ensure_couch_user
|
||||
ensure_runtime_config
|
||||
'
|
||||
)"
|
||||
|
||||
grep -Fq '[dry-run] useradd --create-home --home-dir /home/couch --shell /bin/bash --user-group couch' <<<"${output}"
|
||||
grep -Fq '/run/user/<missing-couch-uid>' <<<"${output}"
|
||||
|
||||
check_output_file="${TMP_DIR}/check.out"
|
||||
set +e
|
||||
PATH="${BIN_DIR}:${PATH}" bash "${REPO_ROOT}/setup.sh" --phase couch --check >"${check_output_file}" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
|
||||
if [[ "${status}" -eq 0 ]]; then
|
||||
printf 'expected setup --check to fail on missing couch user\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
grep -Fq '[check] FAIL couch user missing' "${check_output_file}"
|
||||
grep -Fq '[check] FAIL /home/couch/.config/couchd/runtime.json missing' "${check_output_file}"
|
||||
73
tests/test_status.py
Normal file
73
tests/test_status.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from couchd.discovery import DiscoveryConfig, SlippiDiscovery
|
||||
from couchd.status import CommandError, CommandRunner, StatusCollector, probe_display, probe_slippi_version
|
||||
|
||||
|
||||
class StatusCollectorTests(unittest.TestCase):
|
||||
def test_status_degrades_gracefully_when_probes_fail(self) -> None:
|
||||
collector = StatusCollector(
|
||||
display_probe=lambda: (_ for _ in ()).throw(CommandError("xrandr missing")),
|
||||
slippi_probe=lambda: (_ for _ in ()).throw(TimeoutError("slow launcher")),
|
||||
dolphin_probe=lambda: (_ for _ in ()).throw(TimeoutError("slow dolphin")),
|
||||
controller_probe=lambda: (_ for _ in ()).throw(CommandError("bluetoothctl missing")),
|
||||
)
|
||||
|
||||
status = collector.collect(state="idle", pid=None)
|
||||
|
||||
self.assertEqual(status["state"], "idle")
|
||||
self.assertEqual(status["display"]["mode"], "unknown")
|
||||
self.assertIn("xrandr missing", status["display"]["error"])
|
||||
self.assertEqual(status["slippi_version"], "unknown")
|
||||
self.assertIn("slow launcher", status["slippi_error"])
|
||||
self.assertEqual(status["controller"]["connected"], "unknown")
|
||||
|
||||
def test_probe_display_reports_active_starred_mode_refresh(self) -> None:
|
||||
class Runner(CommandRunner):
|
||||
def run(self, argv: list[str], timeout: int = 5): # type: ignore[override]
|
||||
del argv, timeout
|
||||
return type(
|
||||
"Completed",
|
||||
(),
|
||||
{
|
||||
"returncode": 0,
|
||||
"stdout": "\n".join(
|
||||
[
|
||||
"HDMI-1 connected primary 1920x1080+0+0",
|
||||
" 1920x1080 60.00*+ 59.94",
|
||||
"eDP-1 connected 1920x1080+1920+0",
|
||||
" 1920x1080 120.00*+ 60.00",
|
||||
]
|
||||
),
|
||||
"stderr": "",
|
||||
},
|
||||
)()
|
||||
|
||||
display = probe_display(Runner())
|
||||
|
||||
self.assertEqual(display["name"], "HDMI-1")
|
||||
self.assertEqual(display["mode"], "1920x1080@60.00")
|
||||
self.assertEqual(display["connected_outputs"]["eDP-1"], "1920x1080@120.00")
|
||||
|
||||
def test_probe_slippi_version_prefers_binary_version_output(self) -> None:
|
||||
class Runner(CommandRunner):
|
||||
def run(self, argv: list[str], timeout: int = 5): # type: ignore[override]
|
||||
del timeout
|
||||
if argv[-1] == "--version":
|
||||
return type(
|
||||
"Completed",
|
||||
(),
|
||||
{"returncode": 0, "stdout": "Slippi Launcher 3.2.1\n", "stderr": ""},
|
||||
)()
|
||||
raise AssertionError(argv)
|
||||
|
||||
discovery = SlippiDiscovery(
|
||||
DiscoveryConfig(
|
||||
couch_home=Path("/home/couch"),
|
||||
applications_dir=Path("/home/couch/Applications"),
|
||||
)
|
||||
)
|
||||
discovery.find_launcher = lambda: Path("/home/couch/Applications/SlippiLauncher.AppImage") # type: ignore[method-assign]
|
||||
|
||||
self.assertEqual(probe_slippi_version(discovery, Runner()), "Slippi Launcher 3.2.1")
|
||||
Reference in New Issue
Block a user