screen-locker/screen_locker/tests/test_workout_session.py
Krzysztof kuhy Rudnicki 4d1d2ea64d Put the demo's escape hatch inside the app, the only place it can be received
Both previous escapes were unreachable, for the same underlying reason stated
two different ways. A Tk key binding cannot fire, because once the workout app
takes its seat grab X delivers every keystroke to that app alone. And "run
touch from another terminal" cannot be done, because the app covers the screen
with an override-redirect fullscreen window -- there is no other terminal to
reach. An escape has to be handled INSIDE the grab, by the app itself, or it
is not an escape.

So --demo-escape arms a key handler in the runner: Ctrl+Shift+Q ungrabs the
seat and closes the window. The grab, the override-redirect window and the
handoff are all unchanged, so the demo exercises the real locking mechanism
rather than a softened copy of it, and the hatch is the one deliberate way
out.

The handler ungrabs before closing. Destroying a window that still holds the
seat can leave the pointer and keyboard grabbed with nothing left to release
them, which needs exactly the reboot this hatch exists to prevent.

Not Escape, which the Dart side already binds for in-app navigation; shadowing
it would change the behaviour under test.

Verified in both directions against the real debug bundle, each under its own
Xvfb display: with the flag, the app exits on Ctrl+Shift+Q while holding the
grab and the lock takes the screen back; without it, the identical keystroke
does nothing and the app keeps the screen. A test asserts the production path
never passes the flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
2026-08-22 12:41:07 +02:00

239 lines
8.2 KiB
Python

"""Tests for running the workout app without freezing the lock's event loop.
The invariant these defend: no phase blocks. Every wait is an ``after``
callback, because a blocked event loop makes the lock's own escapes inert --
which is what once forced a hard reboot out of a "safe" demo.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from screen_locker._workout_app import GrabHandoff, ProcessHooks
from screen_locker._workout_ready import READY_MARKER
from screen_locker._workout_session import WorkoutSession
if TYPE_CHECKING:
from collections.abc import Callable
class _FakeAfter:
"""A drainable stand-in for ``root.after``."""
def __init__(self) -> None:
self.queue: list[Callable[[], None]] = []
def __call__(self, _ms: int, callback: Callable[[], None]) -> object:
self.queue.append(callback)
return object()
def drain(self, limit: int = 5000) -> None:
for _ in range(limit):
if not self.queue:
return
self.queue.pop(0)()
class _FakeStdout:
def __init__(self, chunks: list[str]) -> None:
self._chunks = chunks
def readline(self) -> str:
return self._chunks.pop(0) if self._chunks else ""
class _FakeChild:
def __init__(self, chunks: list[str], *, exits_after: int = 1) -> None:
self.stdout = _FakeStdout(chunks)
self.returncode: int | None = None
self.terminated = False
self._polls = 0
self._exits_after = exits_after
def poll(self) -> int | None:
self._polls += 1
if self._polls >= self._exits_after:
self.returncode = 0
return 0
return None
def terminate(self) -> None:
self.terminated = True
self.returncode = -15
def _session(child: _FakeChild, after: _FakeAfter, calls: list[str]):
return WorkoutSession(
GrabHandoff(
release=lambda: calls.append("release"),
reacquire=lambda: calls.append("reacquire"),
),
after=after,
on_status=lambda text: calls.append(f"status:{text[:20]}"),
binary=Path("/nonexistent/workout_app"),
hooks=ProcessHooks(
popen=lambda *_a, **_k: child,
selector=lambda r, _w, _x, _t: (r, [], []),
),
)
class TestWorkoutSession:
"""Each phase hands control back to the event loop."""
def test_releases_the_grab_only_after_the_ready_line(self) -> None:
after, calls = _FakeAfter(), []
child = _FakeChild([f"{READY_MARKER}\n"], exits_after=2)
_session(child, after, calls).start()
assert "release" not in calls # nothing released synchronously
after.drain()
assert "release" in calls
assert calls.index("release") < calls.index("reacquire")
def test_never_releases_when_the_app_dies_before_ready(self) -> None:
after, calls = _FakeAfter(), []
_session(_FakeChild([]), after, calls).start()
after.drain()
assert "release" not in calls
def test_reacquires_when_the_app_exits(self) -> None:
after, calls = _FakeAfter(), []
child = _FakeChild([f"{READY_MARKER}\n"], exits_after=3)
_session(child, after, calls).start()
after.drain()
assert calls[-2] == "reacquire"
assert "Workout app closed" in calls[-1]
def test_abort_terminates_the_app_and_restores_the_lock(self) -> None:
"""The escape hatch: usable at any point, from outside X."""
after, calls = _FakeAfter(), []
child = _FakeChild([f"{READY_MARKER}\n"], exits_after=99)
session = _session(child, after, calls)
session.start()
for _ in range(3):
if after.queue:
after.queue.pop(0)()
session.abort()
assert child.terminated
assert "reacquire" in calls
def test_abort_before_release_does_not_reacquire(self) -> None:
"""Nothing was lent, so there is nothing to take back."""
after, calls = _FakeAfter(), []
session = _session(_FakeChild([], exits_after=99), after, calls)
session.start()
session.abort()
assert "reacquire" not in calls
def test_times_out_without_ever_blocking(self) -> None:
after, calls = _FakeAfter(), []
child = _FakeChild(["noise\n"] * 10, exits_after=99)
session = _session(child, after, calls)
session._remaining = 3
session.start()
after.drain()
assert child.terminated
assert "release" not in calls
def test_says_so_when_the_app_is_not_built(self) -> None:
after, calls = _FakeAfter(), []
session = WorkoutSession(
GrabHandoff(release=lambda: None, reacquire=lambda: None),
after=after,
on_status=calls.append,
hooks=ProcessHooks(popen=lambda *_a, **_k: None),
)
# Force the unbuilt case regardless of what this machine has built.
session._binary = None
monkey = "screen_locker._workout_session.workout_app_binary"
import unittest.mock
with unittest.mock.patch(monkey, return_value=None):
session.start()
assert any("not built" in c for c in calls)
def test_handles_a_missing_stdout_pipe(self) -> None:
after, calls = _FakeAfter(), []
child = _FakeChild([])
child.stdout = None
_session(child, after, calls).start()
after.drain()
assert "release" not in calls
class TestSessionEdges:
"""Paths a fake pipe or a happy path does not reach."""
def test_keeps_waiting_when_the_pipe_has_nothing_yet(self) -> None:
"""A quiet app is not a dead app; poll again, never block."""
after, calls = _FakeAfter(), []
child = _FakeChild([f"{READY_MARKER}\n"], exits_after=99)
session = WorkoutSession(
GrabHandoff(
release=lambda: calls.append("release"),
reacquire=lambda: calls.append("reacquire"),
),
after=after,
on_status=lambda _t: None,
binary=Path("/nonexistent/workout_app"),
hooks=ProcessHooks(
popen=lambda *_a, **_k: child,
# Never readable: the app is up but silent.
selector=lambda _r, _w, _x, _t: ([], [], []),
),
)
session._remaining = 2
session.start()
after.drain()
assert "release" not in calls
assert child.terminated
def test_abort_with_no_child_is_harmless(self) -> None:
after, calls = _FakeAfter(), []
session = _session(_FakeChild([]), after, calls)
session.abort() # never started
assert len(calls) == 1
assert "Workout app closed" in calls[0]
def test_reads_a_real_pipe_without_waiting_for_a_newline(self) -> None:
"""The production path: a text stream wrapping a binary buffer."""
import os
from screen_locker._workout_session import _read_available
read_fd, write_fd = os.pipe()
with os.fdopen(read_fd, "r") as reader, os.fdopen(write_fd, "w") as writer:
writer.write("WORKOUT_")
writer.flush()
assert _read_available(reader) == "WORKOUT_"
class TestDemoEscapeFlag:
"""The flag that arms the app's own escape hatch."""
def _argv_for(self, *, demo_escape: bool) -> list[str]:
seen: list[list[str]] = []
def _popen(argv: list[str], **_kwargs: object) -> _FakeChild:
seen.append(argv)
return _FakeChild([])
WorkoutSession(
GrabHandoff(release=lambda: None, reacquire=lambda: None),
after=_FakeAfter(),
on_status=lambda _t: None,
binary=Path("/nonexistent/workout_app"),
hooks=ProcessHooks(popen=_popen, demo_escape=demo_escape),
).start()
return seen[0]
def test_production_never_arms_the_hatch(self) -> None:
"""The default must not hand the user a way out of a real lock."""
assert "--demo-escape" not in self._argv_for(demo_escape=False)
def test_demo_arms_the_hatch(self) -> None:
argv = self._argv_for(demo_escape=True)
assert argv[-1] == "--demo-escape"
assert "--lock-mode" in argv