Files
shd.build/tools/game-data-capture/tests/integration/test_runtime_cli.py
T

497 lines
17 KiB
Python

from __future__ import annotations
import io
import subprocess
from collections import deque
from collections.abc import Iterable, Mapping
from contextlib import AbstractContextManager, nullcontext
from pathlib import Path
from threading import Event
import pytest
import uvicorn
import game_data_capture.cli as cli
import game_data_capture.runtime as runtime_module
from game_data_capture.config import AppConfig, LifecycleConfig, ServerConfig
from game_data_capture.ingest.clipboard import (
ClipboardIntake,
FocusedWindowMetadata,
IntakeReceipt,
WatchConfig,
)
from game_data_capture.ingest.spool_helper import PNG_SIGNATURE, receipt_name, spool_png
from game_data_capture.paths import RuntimePaths
from game_data_capture.runtime import CaptureRuntime, InstanceLock, RuntimeFailure, _CaptureControl
from game_data_capture.storage import CaptureRepository, ContentAddressedBlobStore, StorageDatabase
_PNG = PNG_SIGNATURE + b"watch-png"
class FakeIntake:
def __init__(self, cancelled: Event, payloads: list[dict[str, object]]) -> None:
self.cancelled = cancelled
self.payloads = deque(payloads)
self.receipts: deque[IntakeReceipt] = deque()
self.armed = self.reconciled = self.shutdown_called = False
self.opened = 0
self.acknowledged: list[str] = []
self.seen_tokens: set[str] = set()
self._ticks = 0
def reconcile(self) -> list[str]:
self.reconciled = True
return []
def arm(self) -> None:
self.armed = True
def tick(self, now: float | None = None) -> None:
self._ticks += 1
if self._ticks > 1:
self.cancelled.set()
def take_helper_receipt(self) -> dict[str, object] | None:
return self.payloads.popleft() if self.payloads else None
def accept_helper_receipt(
self,
payload: Mapping[str, object],
*,
occurrence_id: str,
processing_version: str,
) -> bool:
token = str(payload["receipt_token"])
if token in self.seen_tokens:
return False
self.seen_tokens.add(token)
self.receipts.append(
IntakeReceipt(
occurrence_id,
token,
"0" * 64,
len(_PNG),
"image/png",
Path("/private/spool.png"),
"capture-v1",
processing_version,
FocusedWindowMetadata(None, None, None),
)
)
return True
def take(self) -> IntakeReceipt | None:
return self.receipts.popleft() if self.receipts else None
def open_verified(self, receipt: IntakeReceipt) -> AbstractContextManager[Iterable[bytes]]:
self.opened += 1
return nullcontext([_PNG])
def acknowledge(self, receipt: IntakeReceipt) -> bool:
self.acknowledged.append(receipt.receipt_token)
return True
def shutdown(self) -> None:
self.shutdown_called = True
def _runtime(tmp_path: Path, intake: FakeIntake) -> CaptureRuntime:
root = tmp_path / "xdg"
paths = RuntimePaths(
config_dir=(root / "config").resolve(),
data_dir=(root / "data").resolve(),
cache_dir=(root / "cache").resolve(),
runtime_dir=(root / "runtime").resolve(),
)
return CaptureRuntime(
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0)),
paths,
migrations_dir=(Path(__file__).parents[2] / "migrations").resolve(),
intake_factory=lambda _config: intake,
)
def _payload(token: str) -> dict[str, object]:
return {
"receipt_version": 1,
"receipt_token": token,
"sha256": "0" * 64,
"byte_size": len(_PNG),
"media_type": "image/png",
"spool_name": "ignored.png",
"policy_version": "capture-v1",
}
class PassiveClipboardIntake(ClipboardIntake):
"""Use real private spool sidecars without launching a Wayland watcher."""
def __init__(
self, config: WatchConfig, cancelled: Event, *, acknowledge_result: bool = True
) -> None:
super().__init__(config)
self.cancelled = cancelled
self.acknowledge_result = acknowledge_result
self.acknowledged: list[str] = []
self.reconcile_calls = 0
self._ticks = 0
def reconcile(self) -> list[str]:
self.reconcile_calls += 1
return super().reconcile()
def arm(self) -> None:
self.armed, self.paused = True, False
def tick(self, now: float | None = None) -> None:
self._ticks += 1
if self._ticks > 1:
self.cancelled.set()
def acknowledge(self, receipt: IntakeReceipt) -> bool:
self.acknowledged.append(receipt.receipt_token)
return self.acknowledge_result and super().acknowledge(receipt)
class StopTimeoutReceiptIntake(PassiveClipboardIntake):
def __init__(self, config: WatchConfig, cancelled: Event) -> None:
super().__init__(config, cancelled)
self.tick_entered = Event()
self.release_tick = Event()
def tick(self, now: float | None = None) -> None:
self.tick_entered.set()
self.release_tick.wait()
def shutdown(self) -> None:
self.release_tick.set()
super().shutdown()
def _durable_runtime(
tmp_path: Path, cancelled: Event, *, acknowledge_result: bool = True
) -> tuple[CaptureRuntime, list[PassiveClipboardIntake]]:
created: list[PassiveClipboardIntake] = []
root = tmp_path / "durable-xdg"
paths = RuntimePaths(
config_dir=(root / "config").resolve(),
data_dir=(root / "data").resolve(),
cache_dir=(root / "cache").resolve(),
runtime_dir=(root / "runtime").resolve(),
)
def factory(config: WatchConfig) -> PassiveClipboardIntake:
intake = PassiveClipboardIntake(config, cancelled, acknowledge_result=acknowledge_result)
created.append(intake)
return intake
return (
CaptureRuntime(
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0)),
paths,
migrations_dir=(Path(__file__).parents[2] / "migrations").resolve(),
intake_factory=factory,
),
created,
)
def _durable_receipt(runtime: CaptureRuntime, token: str) -> None:
spool_png(
io.BytesIO(_PNG),
spool=runtime.paths.cache_dir / "spool",
max_bytes=100,
receipt_token=token,
policy_version="capture-v1",
)
def test_watch_streams_verified_receipts_and_replays_exact_tokens_idempotently(
tmp_path: Path,
) -> None:
cancelled = Event()
intake = FakeIntake(cancelled, [_payload("watch-receipt-1"), _payload("watch-receipt-1")])
runtime = _runtime(tmp_path, intake)
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
database = StorageDatabase(runtime.paths.data_dir, runtime.migrations_dir)
with database.connect() as connection:
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 1
assert connection.execute("SELECT count(*) FROM blobs").fetchone()[0] == 1
assert intake.armed and intake.reconciled and intake.shutdown_called and intake.opened == 1
assert intake.acknowledged == ["watch-receipt-1"]
def test_watch_ignores_an_absent_helper_receipt_without_error_noise(tmp_path: Path) -> None:
cancelled = Event()
intake = FakeIntake(cancelled, [])
runtime = _runtime(tmp_path, intake)
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
database = StorageDatabase(runtime.paths.data_dir, runtime.migrations_dir)
with database.connect() as connection:
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 0
assert intake.shutdown_called and intake.opened == 0
def test_startup_reconciles_partial_blob_storage_before_arming(tmp_path: Path) -> None:
cancelled = Event()
intake = FakeIntake(cancelled, [])
runtime = _runtime(tmp_path, intake)
blobs = ContentAddressedBlobStore(runtime.paths.data_dir)
blobs.temporary.mkdir(parents=True)
partial = blobs.temporary / ".leftover.partial"
partial.write_bytes(b"partial")
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
assert not partial.exists()
assert intake.reconciled and intake.shutdown_called
def test_durable_receipt_is_acknowledged_once_after_repository_confirmation(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
cancelled = Event()
runtime, created = _durable_runtime(tmp_path, cancelled)
_durable_receipt(runtime, "confirmed")
events: list[str] = []
original_receive = CaptureRepository.receive
original_acknowledge = PassiveClipboardIntake.acknowledge
def tracked_receive(
self: CaptureRepository,
capture_id: str,
receipt_token: str,
chunks: Iterable[bytes],
*,
media_type: str,
max_bytes: int,
) -> str:
events.append("receive")
return original_receive(
self,
capture_id,
receipt_token,
chunks,
media_type=media_type,
max_bytes=max_bytes,
)
monkeypatch.setattr(CaptureRepository, "receive", tracked_receive)
def tracked_acknowledge(self: PassiveClipboardIntake, receipt: IntakeReceipt) -> bool:
events.append("acknowledge")
return original_acknowledge(self, receipt)
monkeypatch.setattr(PassiveClipboardIntake, "acknowledge", tracked_acknowledge)
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
assert events == ["receive", "acknowledge"]
assert created[0].acknowledged == ["confirmed"]
assert created[0].reconcile_calls == 1
assert not (runtime.paths.cache_dir / "spool" / receipt_name("confirmed")).exists()
def test_storage_failure_leaves_durable_receipt_unacknowledged(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
cancelled = Event()
runtime, created = _durable_runtime(tmp_path, cancelled)
_durable_receipt(runtime, "storage-failure")
def failed_receive(
self: CaptureRepository,
capture_id: str,
receipt_token: str,
chunks: Iterable[bytes],
*,
media_type: str,
max_bytes: int,
) -> str:
raise OSError("storage unavailable")
monkeypatch.setattr(CaptureRepository, "receive", failed_receive)
with pytest.raises(RuntimeFailure, match="capture watch failed"):
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
assert created[0].acknowledged == []
assert (runtime.paths.cache_dir / "spool" / receipt_name("storage-failure")).exists()
def test_cancellation_after_storage_confirmation_does_not_acknowledge(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
cancelled = Event()
runtime, created = _durable_runtime(tmp_path, cancelled)
_durable_receipt(runtime, "cancelled-after-storage")
original_receive = CaptureRepository.receive
def cancel_after_receive(
self: CaptureRepository,
capture_id: str,
receipt_token: str,
chunks: Iterable[bytes],
*,
media_type: str,
max_bytes: int,
) -> str:
result = original_receive(
self,
capture_id,
receipt_token,
chunks,
media_type=media_type,
max_bytes=max_bytes,
)
cancelled.set()
return result
monkeypatch.setattr(CaptureRepository, "receive", cancel_after_receive)
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
assert created[0].acknowledged == []
assert (runtime.paths.cache_dir / "spool" / receipt_name("cancelled-after-storage")).exists()
def test_acknowledgement_failure_replays_idempotently_and_releases_lock(tmp_path: Path) -> None:
cancelled = Event()
runtime, failed = _durable_runtime(tmp_path, cancelled, acknowledge_result=False)
_durable_receipt(runtime, "replay-token")
with pytest.raises(RuntimeFailure, match="acknowledgement failed"):
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
assert failed[0].acknowledged == ["replay-token"]
sidecar = runtime.paths.cache_dir / "spool" / receipt_name("replay-token")
assert sidecar.exists()
reusable = InstanceLock.acquire(runtime.paths.instance_lock_file, 0)
reusable.release()
replay_cancelled = Event()
replay_runtime, replayed = _durable_runtime(tmp_path, replay_cancelled)
replay_runtime.watch(cancelled=replay_cancelled, poll_seconds=0.001)
database = StorageDatabase(replay_runtime.paths.data_dir, replay_runtime.migrations_dir)
with database.connect() as connection:
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 1
assert replayed[0].acknowledged == ["replay-token"]
assert not sidecar.exists()
def test_service_stop_timeout_preserves_unacknowledged_durable_sidecar(tmp_path: Path) -> None:
cancelled = Event()
created: list[StopTimeoutReceiptIntake] = []
root = tmp_path / "stop-timeout-xdg"
paths = RuntimePaths(
config_dir=(root / "config").resolve(),
data_dir=(root / "data").resolve(),
cache_dir=(root / "cache").resolve(),
runtime_dir=(root / "runtime").resolve(),
)
def factory(config: WatchConfig) -> StopTimeoutReceiptIntake:
intake = StopTimeoutReceiptIntake(config, cancelled)
created.append(intake)
return intake
runtime = CaptureRuntime(
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0, shutdown_timeout_seconds=1)),
paths,
migrations_dir=(Path(__file__).parents[2] / "migrations").resolve(),
intake_factory=factory,
)
_durable_receipt(runtime, "timeout-receipt")
resources = runtime._start()
control = _CaptureControl(runtime, resources.repository)
try:
assert control.start_session() == "started"
assert created[0].tick_entered.wait(0.2)
assert control.stop_session() == "stopped"
assert created[0].acknowledged == []
assert (runtime.paths.cache_dir / "spool" / receipt_name("timeout-receipt")).exists()
finally:
control.shutdown()
resources.close()
def test_cli_bare_watch_is_a_safe_refusal_and_modes_never_start_live_processes(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
root = tmp_path / "fresh-xdg"
for key, name in (
("HOME", "home"),
("XDG_CONFIG_HOME", "config"),
("XDG_DATA_HOME", "data"),
("XDG_CACHE_HOME", "cache"),
("XDG_RUNTIME_DIR", "runtime"),
):
monkeypatch.setenv(key, str(root / name))
def forbidden(*_arguments: object, **_kwargs: object) -> None:
raise AssertionError("test started a process")
monkeypatch.setattr(subprocess, "Popen", forbidden)
assert cli.main(["watch"]) == cli.EXIT_UNIMPLEMENTED
assert "requires gdc-runtime-integration" in capsys.readouterr().err
assert cli.main(["watch", "--dry-run"]) == 0
assert capsys.readouterr().out == "watch dry-run: capture runtime plan is valid\n"
assert not root.exists()
def test_cli_arm_reaches_only_a_mocked_runtime(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[str] = []
class FakeRuntime:
def __init__(self, _config: object, _paths: object) -> None:
calls.append("created")
def watch(self) -> None:
calls.append("watch")
monkeypatch.setattr(cli, "CaptureRuntime", FakeRuntime)
assert cli.main(["watch", "--arm"]) == 0
assert calls == ["created", "watch"]
def test_cli_serve_validates_before_constructing_resources_and_passes_exact_authority(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[tuple[str, object]] = []
class FakeRuntime:
def __init__(self, config: AppConfig, _paths: object) -> None:
calls.append(("created", config.server))
def serve(self) -> None:
calls.append(("serve", None))
monkeypatch.setattr(cli, "CaptureRuntime", FakeRuntime)
assert cli.main(["serve", "--host", "127.0.0.1", "--port", "9877"]) == 0
assert calls == [("created", ServerConfig("127.0.0.1", 9877)), ("serve", None)]
calls.clear()
assert cli.main(["serve", "--host", "0.0.0.0"]) == cli.EXIT_CONFIG_ERROR
assert calls == []
def test_cli_serve_converts_uvicorn_startup_exit_without_leaking_its_token(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
root = tmp_path / "serve-xdg"
for key, name in (
("HOME", "home"),
("XDG_CONFIG_HOME", "config"),
("XDG_DATA_HOME", "data"),
("XDG_CACHE_HOME", "cache"),
("XDG_RUNTIME_DIR", "runtime"),
):
monkeypatch.setenv(key, str(root / name))
token = "Z" * 43
monkeypatch.setattr(runtime_module, "new_bootstrap_token", lambda: token)
def failed_server(*_: object, **__: object) -> None:
raise SystemExit(2)
monkeypatch.setattr(uvicorn, "run", failed_server)
assert cli.main(["serve"]) == cli.EXIT_RUNTIME_ERROR
output = capsys.readouterr()
assert output.err == "capture runtime failed\n"
assert token not in output.err
assert "SystemExit" not in output.err
reusable = InstanceLock.acquire(root / "runtime" / "shd-capture" / "instance.lock", 0)
reusable.release()