296 lines
10 KiB
Python
296 lines
10 KiB
Python
import os
|
|
from unittest.mock import MagicMock, patch
|
|
import pytest
|
|
from filelock import FileLock
|
|
|
|
from artemis.runtime.awake_service import (
|
|
AWAKE_STRATEGY_HEARTBEAT,
|
|
AWAKE_STRATEGY_USB,
|
|
ScreenAwakeService,
|
|
_configure_usb_stay_awake,
|
|
_discover_connected_device_ids,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated_awake_owner_locks(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"artemis.runtime.awake_service._owner_lock",
|
|
lambda device_id: FileLock(tmp_path / f"{device_id}.lock", thread_local=False),
|
|
)
|
|
|
|
|
|
def completed(stdout="", returncode=0, stderr=""):
|
|
return MagicMock(returncode=returncode, stdout=stdout, stderr=stderr)
|
|
|
|
|
|
@pytest.mark.parametrize("stage", ["directory", "acquire"])
|
|
def test_inaccessible_awake_lock_skips_actions_and_can_recover(stage, monkeypatch, tmp_path):
|
|
owner = FileLock(tmp_path / "recovered.lock", thread_local=False)
|
|
denied_owner = MagicMock()
|
|
denied_owner.acquire.side_effect = PermissionError("awake lock access denied")
|
|
factory = MagicMock(
|
|
side_effect=[
|
|
PermissionError("awake directory denied") if stage == "directory" else denied_owner,
|
|
owner,
|
|
]
|
|
)
|
|
configure = MagicMock(return_value=AWAKE_STRATEGY_USB)
|
|
monkeypatch.setattr("artemis.runtime.awake_service._owner_lock", factory)
|
|
monkeypatch.setattr("artemis.runtime.awake_service._configure_usb_stay_awake", configure)
|
|
service = ScreenAwakeService()
|
|
try:
|
|
assert service.ensure_device("device") is None
|
|
configure.assert_not_called()
|
|
assert service.device_ids == ()
|
|
assert service.ensure_device("device") == AWAKE_STRATEGY_USB
|
|
configure.assert_called_once_with("device")
|
|
finally:
|
|
service.shutdown()
|
|
|
|
|
|
def test_awake_monitor_continues_to_other_devices_after_access_denied(monkeypatch, tmp_path):
|
|
def owner(device):
|
|
if device == "denied":
|
|
raise PermissionError("denied")
|
|
return FileLock(tmp_path / f"{device}.lock", thread_local=False)
|
|
|
|
monkeypatch.setattr("artemis.runtime.awake_service._owner_lock", owner)
|
|
configure = MagicMock(return_value=AWAKE_STRATEGY_USB)
|
|
monkeypatch.setattr("artemis.runtime.awake_service._configure_usb_stay_awake", configure)
|
|
service = ScreenAwakeService()
|
|
try:
|
|
service._reconcile_connected_devices({"denied", "healthy"})
|
|
service._reconcile_connected_devices({"denied", "healthy"})
|
|
assert service.device_ids == ("healthy",)
|
|
configure.assert_called_once_with("healthy")
|
|
finally:
|
|
service.shutdown()
|
|
|
|
|
|
@patch(
|
|
"artemis.runtime.awake_service.ScreenAwakeLease",
|
|
side_effect=PermissionError("legacy directory denied"),
|
|
)
|
|
@patch("artemis.runtime.awake_service.subprocess.run")
|
|
def test_optional_legacy_directory_failure_does_not_abort_usb_policy(mock_run, _lease):
|
|
mock_run.side_effect = [
|
|
completed(),
|
|
completed(),
|
|
completed(),
|
|
completed("2"),
|
|
completed("mStayOn=true"),
|
|
]
|
|
assert _configure_usb_stay_awake("device") == AWAKE_STRATEGY_USB
|
|
|
|
|
|
@patch(
|
|
"artemis.runtime.awake_service._configure_usb_stay_awake", return_value=AWAKE_STRATEGY_HEARTBEAT
|
|
)
|
|
def test_shared_device_has_one_heartbeat_owner_and_release_allows_takeover(configure):
|
|
first, second = ScreenAwakeService(), ScreenAwakeService()
|
|
try:
|
|
assert first.ensure_device("shared") == AWAKE_STRATEGY_HEARTBEAT
|
|
assert second.ensure_device("shared") is None
|
|
assert configure.call_count == 1
|
|
thread = first._heartbeat_threads["shared"]
|
|
first.release_device("shared")
|
|
assert not thread.is_alive()
|
|
assert second.ensure_device("shared") == AWAKE_STRATEGY_HEARTBEAT
|
|
assert configure.call_count == 2
|
|
finally:
|
|
first.shutdown()
|
|
second.shutdown()
|
|
|
|
|
|
@patch("artemis.runtime.awake_service.ScreenAwakeLease")
|
|
@patch("artemis.runtime.awake_service.subprocess.run")
|
|
def test_usb_policy_is_primary_when_android_reports_it_active(mock_run, lease_type):
|
|
mock_run.side_effect = [
|
|
completed(),
|
|
completed(),
|
|
completed(),
|
|
completed("2\n"),
|
|
completed(" mIsPowered=true\n mPlugType=2\n mStayOn=true\n"),
|
|
]
|
|
|
|
assert _configure_usb_stay_awake("device-123") == AWAKE_STRATEGY_USB
|
|
|
|
lease_type.assert_called_once_with("device-123")
|
|
lease_type.return_value.cleanup_unowned_references.assert_called_once_with()
|
|
commands = [call.args[0] for call in mock_run.call_args_list]
|
|
# Commands are endpoint-qualified (adb -H host -P port -s serial ...);
|
|
# assert on the payload after the serial rather than a fixed prefix width.
|
|
payloads = [command[command.index("-s") + 2 :] for command in commands if "-s" in command]
|
|
assert ["shell", "svc", "power", "stayon", "usb"] in payloads
|
|
assert not any("SCREEN_BRIGHT_WAKE_LOCK" in command for command in commands)
|
|
assert not any("KEYCODE_UNKNOWN" in command for command in commands)
|
|
|
|
|
|
@patch.dict(os.environ, {"ARTEMIS_TASK_WORKER": "1"})
|
|
@patch("artemis.runtime.awake_service.ScreenAwakeLease")
|
|
@patch("artemis.runtime.awake_service.subprocess.run")
|
|
def test_queued_worker_does_not_run_optional_legacy_cleanup(mock_run, lease_type):
|
|
mock_run.side_effect = [
|
|
completed(),
|
|
completed(),
|
|
completed(),
|
|
completed("2\n"),
|
|
completed(" mStayOn=true\n"),
|
|
]
|
|
|
|
assert _configure_usb_stay_awake("device-123") == AWAKE_STRATEGY_USB
|
|
lease_type.assert_not_called()
|
|
|
|
|
|
@patch("artemis.runtime.awake_service.ScreenAwakeLease")
|
|
@patch("artemis.runtime.awake_service.subprocess.run")
|
|
def test_inactive_usb_policy_falls_back_to_effective_host_heartbeat(mock_run, _lease_type):
|
|
mock_run.side_effect = [
|
|
completed(),
|
|
completed(),
|
|
completed(),
|
|
completed("2\n"),
|
|
completed(" mIsPowered=true\n mPlugType=2\n mStayOn=false\n"),
|
|
completed(),
|
|
]
|
|
|
|
assert _configure_usb_stay_awake("device-123") == AWAKE_STRATEGY_HEARTBEAT
|
|
|
|
assert mock_run.call_args_list[-1].args[0][-1] == "KEYCODE_UNKNOWN"
|
|
|
|
|
|
@patch.dict("os.environ", {"ARTEMIS_KEEP_DEVICE_AWAKE": "false"})
|
|
@patch("artemis.runtime.awake_service.subprocess.run")
|
|
def test_awake_strategy_can_be_disabled(mock_run):
|
|
assert _configure_usb_stay_awake("device-123") is None
|
|
mock_run.assert_not_called()
|
|
|
|
|
|
@patch("artemis.runtime.awake_service.threading.Thread")
|
|
@patch("artemis.runtime.awake_service.threading.Event")
|
|
@patch(
|
|
"artemis.runtime.awake_service._configure_usb_stay_awake",
|
|
return_value=AWAKE_STRATEGY_HEARTBEAT,
|
|
)
|
|
def test_host_heartbeat_lives_until_host_shutdown(configure, event_type, thread_type):
|
|
service = ScreenAwakeService()
|
|
stop_event = event_type.return_value
|
|
thread = thread_type.return_value
|
|
|
|
service.ensure_device("device-123")
|
|
service.ensure_device("device-123")
|
|
|
|
configure.assert_called_once_with("device-123")
|
|
thread.start.assert_called_once_with()
|
|
stop_event.set.assert_not_called()
|
|
|
|
service.shutdown()
|
|
service.shutdown()
|
|
|
|
stop_event.set.assert_called_once_with()
|
|
thread.join.assert_called_once_with(timeout=2.0)
|
|
|
|
|
|
@patch("artemis.runtime.awake_service._run_awake_adb_command")
|
|
@patch("artemis.runtime.awake_service.threading.Thread")
|
|
@patch("artemis.runtime.awake_service.threading.Event")
|
|
@patch(
|
|
"artemis.runtime.awake_service._configure_usb_stay_awake",
|
|
return_value=AWAKE_STRATEGY_HEARTBEAT,
|
|
)
|
|
def test_heartbeat_loop_uses_verified_user_activity_key(
|
|
_configure, event_type, thread_type, run_command
|
|
):
|
|
stop_event = event_type.return_value
|
|
stop_event.wait.side_effect = [False, True]
|
|
service = ScreenAwakeService()
|
|
|
|
service.ensure_device("device-123")
|
|
heartbeat_target = thread_type.call_args.kwargs["target"]
|
|
heartbeat_target()
|
|
|
|
run_command.assert_called_once_with(
|
|
"device-123",
|
|
["shell", "input", "keyevent", "KEYCODE_UNKNOWN"],
|
|
"send the host stay-awake heartbeat",
|
|
)
|
|
|
|
|
|
@patch("artemis.runtime.awake_service.threading.Thread")
|
|
@patch("artemis.runtime.awake_service.threading.Event")
|
|
@patch("artemis.runtime.awake_service._discover_connected_device_ids")
|
|
@patch(
|
|
"artemis.runtime.awake_service._configure_usb_stay_awake",
|
|
return_value=AWAKE_STRATEGY_USB,
|
|
)
|
|
def test_service_monitor_enrolls_a_device_attached_after_start(
|
|
configure, discover, event_type, thread_type
|
|
):
|
|
discover.side_effect = [[], ["device-late"]]
|
|
stop_event = event_type.return_value
|
|
stop_event.wait.side_effect = [False, True]
|
|
stop_event.is_set.return_value = False
|
|
service = ScreenAwakeService()
|
|
|
|
service.start()
|
|
monitor_target = thread_type.call_args.kwargs["target"]
|
|
monitor_target()
|
|
|
|
configure.assert_called_once_with("device-late")
|
|
assert service.device_ids == ("device-late",)
|
|
|
|
|
|
@patch("artemis.runtime.awake_service.threading.Thread")
|
|
@patch("artemis.runtime.awake_service.threading.Event")
|
|
@patch(
|
|
"artemis.runtime.awake_service._configure_usb_stay_awake",
|
|
return_value=AWAKE_STRATEGY_HEARTBEAT,
|
|
)
|
|
def test_disconnect_reconciliation_stops_heartbeat_and_allows_reenrollment(
|
|
configure, event_type, thread_type
|
|
):
|
|
service = ScreenAwakeService()
|
|
stop_event = event_type.return_value
|
|
thread = thread_type.return_value
|
|
thread.is_alive.return_value = False
|
|
service.ensure_device("device-123")
|
|
|
|
service._reconcile_connected_devices(set())
|
|
|
|
stop_event.set.assert_called_once_with()
|
|
thread.join.assert_called_once_with(timeout=2.0)
|
|
assert service.device_ids == ()
|
|
|
|
service.ensure_device("device-123")
|
|
assert configure.call_count == 2
|
|
|
|
|
|
@patch("artemis.runtime.device_pool.device_pool.get_claimed_serials")
|
|
@patch("artemis.runtime.awake_service.AdbClient")
|
|
def test_discovery_keeps_only_pool_claimed_devices(adb_client, claimed):
|
|
"""The pool manages two devices while the ADB server lists three: only the
|
|
two claimed devices are kept awake; the unrelated one is left untouched."""
|
|
adb_client.return_value.device_list.return_value = [
|
|
MagicMock(serial="device-1"),
|
|
MagicMock(serial="device-2"),
|
|
MagicMock(serial="device-3"),
|
|
]
|
|
claimed.return_value = {"device-1", "device-2"}
|
|
|
|
assert _discover_connected_device_ids() == ["device-1", "device-2"]
|
|
|
|
|
|
@patch(
|
|
"artemis.runtime.device_pool.device_pool.get_claimed_serials",
|
|
return_value=set(),
|
|
)
|
|
@patch("artemis.runtime.awake_service.AdbClient")
|
|
def test_discovery_returns_nothing_when_pool_claims_no_device(adb_client, _claimed):
|
|
adb_client.return_value.device_list.return_value = [
|
|
MagicMock(serial="device-1"),
|
|
MagicMock(serial="device-2"),
|
|
]
|
|
|
|
assert _discover_connected_device_ids() == []
|