snapshot of https://github.com/ActivityWatch/aw-watcher-afk.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build / Test on macos-latest (py-3.9) (push) Has been cancelled
Build / Test on ubuntu-24.04 (py-3.9) (push) Has been cancelled
Build / Test on windows-latest (py-3.9) (push) Has been cancelled
CodeQL / Analyze (python) (push) Failing after 1m36s

This commit is contained in:
upstream-mirror
2026-09-16 00:21:02 +05:00
commit 87aaa2add8
22 changed files with 2929 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
import os
import unittest
from contextlib import ExitStack
from types import SimpleNamespace
from unittest.mock import patch
from aw_watcher_afk import windows
from aw_watcher_afk.afk import AFKWatcher
class WindowsIdleQueryTests(unittest.TestCase):
def test_get_last_input_failure_raises(self):
with patch.object(
windows.ctypes, "WINFUNCTYPE", create=True
) as prototype_factory:
with patch.object(windows.ctypes, "windll", create=True):
prototype_factory.return_value.return_value.return_value = 0
with self.assertRaisesRegex(OSError, "GetLastInputInfo failed"):
windows._getLastInputTick()
class HeartbeatFailureTests(unittest.TestCase):
def make_watcher(self):
watcher = object.__new__(AFKWatcher)
watcher.settings = SimpleNamespace(timeout=10, poll_time=5)
watcher._initial_ppid = os.getppid()
return watcher
def run_loop(self, watcher, samples, system_name="Windows"):
with ExitStack() as stack:
stack.enter_context(patch("aw_watcher_afk.afk.system", system_name))
stack.enter_context(
patch(
"aw_watcher_afk.afk.seconds_since_last_input",
side_effect=samples,
)
)
ping = stack.enter_context(patch.object(watcher, "ping"))
sleep = stack.enter_context(patch("aw_watcher_afk.afk.sleep"))
log_exception = stack.enter_context(
patch("aw_watcher_afk.afk.logger.exception")
)
watcher.heartbeat_loop()
return ping, sleep, log_exception
def test_failed_sample_preserves_afk_state_and_recovers(self):
watcher = self.make_watcher()
samples = [20.0, OSError("idle API unavailable"), 20.0, KeyboardInterrupt()]
ping, sleep, log_exception = self.run_loop(watcher, samples)
self.assertEqual(
[record.args[0] for record in ping.call_args_list],
[False, True, True],
)
self.assertEqual(sleep.call_count, 3)
self.assertEqual(log_exception.call_count, 1)
def test_repeated_failures_sleep_without_emitting_heartbeats(self):
watcher = self.make_watcher()
samples = [
OSError("idle API unavailable"),
OSError("idle API unavailable"),
KeyboardInterrupt(),
]
ping, sleep, log_exception = self.run_loop(watcher, samples)
ping.assert_not_called()
self.assertEqual(sleep.call_count, 2)
self.assertEqual(log_exception.call_count, 2)
def test_non_windows_failure_reaches_supervisor(self):
watcher = self.make_watcher()
with self.assertRaisesRegex(OSError, "listener failed"):
self.run_loop(watcher, [OSError("listener failed")], system_name="Linux")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,41 @@
# Usage:
# python3 test_continuous_events.py aw-watcher-afk-testing_{hostname}
#
# Example:
# python3 test_continuous_events.py aw-watcher-afk-testing_erb-laptop-ubuntu
import sys
from datetime import timedelta
from aw_client import ActivityWatchClient
client = ActivityWatchClient("aw-watcher-afk-test", testing=True)
print(client.get_buckets())
bucket_id = sys.argv[1]
events = client.get_events(bucket_id) # For example "aw-watcher-afk-testing_erb-laptop-ubuntu"
print("\n\n")
last_event = None
wrong_events = 0
for event in sorted(events, key=lambda e: e.timestamp):
if last_event:
# The diff is the gap between the two events, should be zero
# In reality it is currently sometimes negative and almost always larger than 1s
diff = (event.timestamp - last_event.timestamp) - last_event.duration
print("{} at {}".format(event.label, event.timestamp))
print("Duration: {}".format(event.duration))
if not timedelta(seconds=1) > abs(diff):
print(" WARNING: Diff had absolute value of over 1s ({}), this might be either because of a bug or due to aw-watcher-afk not running continuously".format(diff))
wrong_events += 1
if last_event.label == event.label:
print(" WARNING: Two {} events in a row".format(event.label))
print("")
last_event = event
print("Percent of wrong events: {}".format(wrong_events / len(events)))

View File

@@ -0,0 +1,387 @@
"""Tests for listener health checking and auto-recovery."""
from unittest.mock import MagicMock, patch
from aw_watcher_afk.listeners import GamepadListener, KeyboardListener, MouseListener
def test_keyboard_listener_is_alive_before_start():
listener = KeyboardListener()
assert not listener.is_alive()
def test_mouse_listener_is_alive_before_start():
listener = MouseListener()
assert not listener.is_alive()
def test_keyboard_listener_is_alive_with_mock():
listener = KeyboardListener()
# Simulate a started listener
mock_pynput = MagicMock()
mock_pynput.is_alive.return_value = True
listener._listener = mock_pynput
assert listener.is_alive()
def test_keyboard_listener_dead_after_x_restart():
listener = KeyboardListener()
# Simulate a dead listener (X server died)
mock_pynput = MagicMock()
mock_pynput.is_alive.return_value = False
listener._listener = mock_pynput
assert not listener.is_alive()
def test_mouse_listener_is_alive_with_mock():
listener = MouseListener()
mock_pynput = MagicMock()
mock_pynput.is_alive.return_value = True
listener._listener = mock_pynput
assert listener.is_alive()
def test_mouse_listener_dead_after_x_restart():
listener = MouseListener()
mock_pynput = MagicMock()
mock_pynput.is_alive.return_value = False
listener._listener = mock_pynput
assert not listener.is_alive()
@patch("aw_watcher_afk.unix.KeyboardListener")
@patch("aw_watcher_afk.unix.MouseListener")
@patch("aw_watcher_afk.unix.GamepadListener")
def test_unix_reinitializes_dead_listeners(MockGamepad, MockMouse, MockKeyboard):
"""When listeners die (e.g. X server restart), they should be restarted."""
from aw_watcher_afk.unix import LastInputUnix
# First call: listeners are alive
mock_kb_instance = MockKeyboard.return_value
mock_mouse_instance = MockMouse.return_value
mock_gamepad_instance = MockGamepad.return_value
mock_kb_instance.is_alive.return_value = True
mock_mouse_instance.is_alive.return_value = True
mock_gamepad_instance.is_alive.return_value = False # no gamepad connected
# No gamepad attached → needs_restart() should be False so we don't
# reinitialize every poll on gamepad-less systems.
mock_gamepad_instance.needs_restart.return_value = False
mock_kb_instance.has_new_event.return_value = False
mock_mouse_instance.has_new_event.return_value = False
mock_gamepad_instance.has_new_event.return_value = False
unix = LastInputUnix()
unix.seconds_since_last_input()
# Listeners should NOT have been restarted
assert MockKeyboard.call_count == 1
assert MockMouse.call_count == 1
# Now simulate X server death
mock_kb_instance.is_alive.return_value = False
unix.seconds_since_last_input()
# Listeners should have been restarted (new instances created)
assert MockKeyboard.call_count == 2
assert MockMouse.call_count == 2
assert MockGamepad.call_count == 2
# ---------------------------------------------------------------------------
# GamepadListener tests
# ---------------------------------------------------------------------------
def test_gamepad_listener_not_alive_before_start():
listener = GamepadListener()
assert not listener.is_alive()
def test_gamepad_listener_not_alive_without_evdev(monkeypatch):
"""GamepadListener.start() should be a no-op when evdev is not installed."""
import builtins
real_import = builtins.__import__
def mock_import(name, *args, **kwargs):
if name == "evdev":
raise ImportError("No module named 'evdev'")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", mock_import)
listener = GamepadListener()
listener.start()
assert not listener.is_alive()
def test_gamepad_listener_not_alive_without_devices():
"""GamepadListener.start() should be a no-op when no gamepads are found."""
listener = GamepadListener()
with patch.object(listener, "_find_gamepads", return_value=[]):
listener.start()
assert not listener.is_alive()
def test_gamepad_listener_starts_with_device():
"""GamepadListener.start() should launch a reader thread per device."""
import sys
from unittest.mock import MagicMock
listener = GamepadListener()
mock_device = MagicMock()
mock_device.path = "/dev/input/event5"
mock_device.name = "Xbox Controller"
# evdev is an optional dependency; mock the import so the test runs without it
mock_evdev = MagicMock()
with (
patch.dict(sys.modules, {"evdev": mock_evdev}),
patch.object(listener, "_find_gamepads", return_value=[mock_device]),
patch.object(listener, "_read_events"),
):
listener.start()
assert len(listener._threads) == 1
listener.stop()
def test_gamepad_listener_detects_button_press():
"""Simulated button press should set has_new_event() and count the press."""
listener = GamepadListener()
assert not listener.has_new_event()
# Simulate what _read_events does on BTN_SOUTH press
listener.event_data["buttons"] += 1
listener.new_event.set()
assert listener.has_new_event()
data = listener.next_event()
assert data["buttons"] == 1
# After next_event the counter is reset
assert not listener.has_new_event()
@patch("aw_watcher_afk.unix.KeyboardListener")
@patch("aw_watcher_afk.unix.MouseListener")
@patch("aw_watcher_afk.unix.GamepadListener")
def test_unix_gamepad_event_resets_afk_timer(MockGamepad, MockMouse, MockKeyboard):
"""A gamepad button press should reset the AFK timer."""
from aw_watcher_afk.unix import LastInputUnix
mock_kb_instance = MockKeyboard.return_value
mock_mouse_instance = MockMouse.return_value
mock_gamepad_instance = MockGamepad.return_value
mock_kb_instance.is_alive.return_value = True
mock_mouse_instance.is_alive.return_value = True
mock_gamepad_instance.is_alive.return_value = True
mock_gamepad_instance.needs_restart.return_value = False
# No keyboard/mouse events, but gamepad has an event
mock_kb_instance.has_new_event.return_value = False
mock_mouse_instance.has_new_event.return_value = False
mock_gamepad_instance.has_new_event.return_value = True
unix = LastInputUnix()
seconds = unix.seconds_since_last_input()
# Timer was just reset by the gamepad event → nearly 0 seconds since last input
assert seconds < 1.0
# Event was consumed
mock_gamepad_instance.next_event.assert_called_once()
# ---------------------------------------------------------------------------
# GamepadListener.needs_restart()
# ---------------------------------------------------------------------------
def test_gamepad_needs_restart_false_before_start():
"""needs_restart() must be False before start() so we don't try to restart
a listener that was never running (e.g. on systems with no gamepad)."""
listener = GamepadListener()
assert not listener.needs_restart()
def test_gamepad_needs_restart_false_when_start_was_noop():
"""If start() returns early (no evdev / no devices), needs_restart() must
stay False to avoid an infinite restart loop on gamepad-less systems."""
listener = GamepadListener()
with patch.object(listener, "_find_gamepads", return_value=[]):
listener.start()
assert not listener.is_alive()
assert not listener.needs_restart()
def test_gamepad_needs_restart_true_when_threads_died():
"""After start() spawned threads, needs_restart() should flip to True once
all reader threads have exited (e.g. device unplugged)."""
import sys
from unittest.mock import MagicMock
listener = GamepadListener()
mock_device = MagicMock()
mock_device.path = "/dev/input/event5"
mock_device.name = "Xbox Controller"
mock_evdev = MagicMock()
with (
patch.dict(sys.modules, {"evdev": mock_evdev}),
patch.object(listener, "_find_gamepads", return_value=[mock_device]),
patch.object(listener, "_read_events"), # returns immediately
):
listener.start()
# Wait for the (no-op) reader thread to exit
for t in listener._threads:
t.join(timeout=2.0)
assert not listener.is_alive()
assert listener.needs_restart()
def test_gamepad_needs_restart_false_after_stop():
"""After an explicit stop(), needs_restart() must be False so the caller
doesn't keep recreating a listener the user wanted shut down."""
import sys
from unittest.mock import MagicMock
listener = GamepadListener()
mock_device = MagicMock()
mock_device.path = "/dev/input/event5"
mock_device.name = "Xbox Controller"
mock_evdev = MagicMock()
with (
patch.dict(sys.modules, {"evdev": mock_evdev}),
patch.object(listener, "_find_gamepads", return_value=[mock_device]),
patch.object(listener, "_read_events"),
):
listener.start()
listener.stop()
assert not listener.needs_restart()
@patch("aw_watcher_afk.unix.KeyboardListener")
@patch("aw_watcher_afk.unix.MouseListener")
@patch("aw_watcher_afk.unix.GamepadListener")
def test_unix_restarts_when_only_gamepad_dies(MockGamepad, MockMouse, MockKeyboard):
"""If the gamepad listener dies but mouse/keyboard are still alive, the
listeners should still be reinitialized so a reconnected gamepad starts
being tracked again (regression for the original bug)."""
from aw_watcher_afk.unix import LastInputUnix
mock_kb = MockKeyboard.return_value
mock_mouse = MockMouse.return_value
mock_gamepad = MockGamepad.return_value
mock_kb.is_alive.return_value = True
mock_mouse.is_alive.return_value = True
mock_gamepad.is_alive.return_value = True
mock_gamepad.needs_restart.return_value = False
mock_kb.has_new_event.return_value = False
mock_mouse.has_new_event.return_value = False
mock_gamepad.has_new_event.return_value = False
unix = LastInputUnix()
unix.seconds_since_last_input()
assert MockGamepad.call_count == 1 # not restarted yet
# Now simulate the gamepad dying while mouse/keyboard stay alive
mock_gamepad.is_alive.return_value = False
mock_gamepad.needs_restart.return_value = True
unix.seconds_since_last_input()
# All listeners should have been recreated
assert MockKeyboard.call_count == 2
assert MockMouse.call_count == 2
assert MockGamepad.call_count == 2
def test_gamepad_needs_restart_true_when_one_of_two_threads_dies():
"""needs_restart() must be True if any thread dies, not only when all die.
This covers multi-gamepad setups where one device is removed while another
stays alive; without this the dead device goes silently unmonitored.
Uses direct thread-state injection to avoid mock-revert races with
background threads (see lessons/tools/mock-revert-race-background-threads.md).
"""
import threading
listener = GamepadListener()
listener._was_started = True
# dead_thread: simulates a gamepad that was unplugged (thread exited)
dead_thread = threading.Thread(target=lambda: None, daemon=True)
dead_thread.start()
dead_thread.join(timeout=2.0)
assert not dead_thread.is_alive()
# live_thread: simulates a gamepad still connected (thread still running)
keep_alive = threading.Event()
live_thread = threading.Thread(target=keep_alive.wait, daemon=True)
live_thread.start()
listener._threads = [dead_thread, live_thread]
try:
# is_alive() should see the live thread; needs_restart() should fire on the dead one
assert listener.is_alive()
assert listener.needs_restart()
finally:
keep_alive.set()
live_thread.join(timeout=2.0)
@patch("aw_watcher_afk.unix.KeyboardListener")
@patch("aw_watcher_afk.unix.MouseListener")
@patch("aw_watcher_afk.unix.GamepadListener")
def test_unix_log_message_when_gamepad_and_keyboard_die_together(
MockGamepad, MockMouse, MockKeyboard, caplog
):
"""When gamepad AND mouse/keyboard die simultaneously, the log should say
'All input listeners died' (XServer restart context), not just 'Gamepad
listener died' (device-removal context).
"""
import logging
from aw_watcher_afk.unix import LastInputUnix
mock_kb = MockKeyboard.return_value
mock_mouse = MockMouse.return_value
mock_gamepad = MockGamepad.return_value
mock_kb.is_alive.return_value = True
mock_mouse.is_alive.return_value = True
mock_gamepad.is_alive.return_value = True
mock_gamepad.needs_restart.return_value = False
mock_kb.has_new_event.return_value = False
mock_mouse.has_new_event.return_value = False
mock_gamepad.has_new_event.return_value = False
unix = LastInputUnix()
unix.seconds_since_last_input()
# Simulate all listeners dying at once (XServer crash)
mock_kb.is_alive.return_value = False
mock_mouse.is_alive.return_value = False
mock_gamepad.is_alive.return_value = False
mock_gamepad.needs_restart.return_value = True
with caplog.at_level(logging.WARNING):
unix.seconds_since_last_input()
warning_messages = [r.message for r in caplog.records if r.levelno == logging.WARNING]
assert any("All input listeners died" in m for m in warning_messages), (
f"Expected 'All input listeners died' in log, got: {warning_messages}"
)
assert not any("Gamepad listener died" in m for m in warning_messages), (
f"Should not say 'Gamepad listener died' when all died: {warning_messages}"
)