Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/rdc/_platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,14 @@ def join_cmdline(args: list[str]) -> str:


def data_dir() -> Path:
"""Return the per-user data directory for rdc."""
"""Return the per-user data directory for rdc.

Honours the ``RDC_DATA_DIR`` environment override (mirrors ``RDC_SESSION``);
when unset, falls back to the per-user home-based default.
"""
override = os.environ.get("RDC_DATA_DIR")
if override:
return Path(override)
if _WIN:
base = os.environ.get("LOCALAPPDATA", str(Path.home()))
return Path(base) / "rdc"
Expand Down
68 changes: 55 additions & 13 deletions tests/e2e/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@

from __future__ import annotations

import json
import logging
import os
import uuid
from collections.abc import Generator
from pathlib import Path

import e2e_helpers
import pytest
from e2e_helpers import (
DYNAMIC_RENDERING,
Expand All @@ -25,6 +27,8 @@
self_capture,
)

from rdc import _platform

# ---------------------------------------------------------------------------
# Session-scoped fixtures
# ---------------------------------------------------------------------------
Expand All @@ -33,6 +37,37 @@
_DISCOVER_SESSION = "e2e_discover"


def _reap_daemons(data_dir: Path) -> None:
"""Terminate any daemons still recorded under *data_dir* (best effort)."""
for session_file in data_dir.glob("sessions/*.json"):
try:
pid = int(json.loads(session_file.read_text()).get("pid", 0))
except (OSError, ValueError, TypeError, json.JSONDecodeError):
continue
if pid > 0 and _platform.is_pid_alive(pid):
_platform.terminate_process_tree(pid)


@pytest.fixture(scope="session", autouse=True)
def _isolate_e2e_data_dir(
tmp_path_factory: pytest.TempPathFactory,
) -> Generator[Path, None, None]:
"""Redirect every e2e CLI subprocess to a session-scoped tmp data dir.

Sets ``RDC_DATA_DIR`` for all ``rdc`` subprocesses so the real CLI never
writes the developer's ~/.rdc, and reaps any surviving daemons on teardown.
Scope is session (not function): an e2e daemon is shared across a module's
tests, so per-test isolation would tear shared sessions down mid-run.
"""
data_dir = tmp_path_factory.mktemp("e2e_data") / ".rdc"
e2e_helpers.SUBPROCESS_ENV["RDC_DATA_DIR"] = str(data_dir)
try:
yield data_dir
finally:
_reap_daemons(data_dir)
e2e_helpers.SUBPROCESS_ENV.pop("RDC_DATA_DIR", None)


@pytest.fixture(scope="session")
def captured_rdc(tmp_path_factory: pytest.TempPathFactory) -> Generator[Path, None, None]:
"""Self-capture vkcube or fall back to pre-recorded fixture."""
Expand All @@ -56,10 +91,9 @@ def captured_rdc(tmp_path_factory: pytest.TempPathFactory) -> Generator[Path, No
def capture_meta(captured_rdc: Path) -> CaptureMetadata:
"""Open capture, discover all IDs dynamically, close session."""
session = f"{_DISCOVER_SESSION}_{uuid.uuid4().hex[:8]}"
r = rdc("open", str(captured_rdc), session=session)
assert r.returncode == 0, f"Failed to open capture for discovery: {r.stderr}"

try:
r = rdc("open", str(captured_rdc), session=session)
assert r.returncode == 0, f"Failed to open capture for discovery: {r.stderr}"
return _discover_metadata(session)
finally:
rdc("close", session=session)
Expand All @@ -71,8 +105,10 @@ def can_replay_prerecorded() -> bool:
if not VKCUBE.exists():
return False
name = f"e2e_probe_{uuid.uuid4().hex[:8]}"
r = rdc("open", str(VKCUBE), session=name)
rdc("close", session=name)
try:
r = rdc("open", str(VKCUBE), session=name)
finally:
rdc("close", session=name)
return r.returncode == 0


Expand Down Expand Up @@ -212,10 +248,12 @@ def _discover_metadata(session: str) -> CaptureMetadata:
def vkcube_session(captured_rdc: Path) -> Generator[str, None, None]:
"""Open captured .rdc and yield session name; close on teardown."""
name = f"e2e_vkcube_{uuid.uuid4().hex[:8]}"
r = rdc("open", str(captured_rdc), session=name)
assert r.returncode == 0, f"Failed to open capture: {r.stderr}"
yield name
rdc("close", session=name)
try:
r = rdc("open", str(captured_rdc), session=name)
assert r.returncode == 0, f"Failed to open capture: {r.stderr}"
yield name
finally:
rdc("close", session=name)


@pytest.fixture(scope="module")
Expand All @@ -230,8 +268,10 @@ def dynamic_session() -> Generator[str, None, None]:
r = rdc("open", str(DYNAMIC_RENDERING), session=name)
if r.returncode != 0:
pytest.skip(f"dynamic_rendering.rdc failed to open (GPU mismatch?): {r.stderr}")
yield name
rdc("close", session=name)
try:
yield name
finally:
rdc("close", session=name)


@pytest.fixture(scope="module")
Expand All @@ -246,8 +286,10 @@ def oit_session() -> Generator[str, None, None]:
r = rdc("open", str(OIT_DEPTH_PEELING), session=name)
if r.returncode != 0:
pytest.skip(f"oit_depth_peeling.rdc failed to open (GPU mismatch?): {r.stderr}")
yield name
rdc("close", session=name)
try:
yield name
finally:
rdc("close", session=name)


@pytest.fixture(scope="session")
Expand Down
12 changes: 12 additions & 0 deletions tests/e2e/e2e_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ class CaptureMetadata:

VKCUBE_BIN: str | None = os.environ.get("VKCUBE_BIN") or shutil.which("vkcube")

# Extra environment merged into every CLI subprocess. The session-scoped
# isolation fixture in conftest.py populates this with ``RDC_DATA_DIR`` so the
# real CLI never touches the developer's ~/.rdc or leaks live daemons.
SUBPROCESS_ENV: dict[str, str] = {}


def _subprocess_env() -> dict[str, str]:
"""Return the full environment for a CLI subprocess (os.environ + overrides)."""
return {**os.environ, **SUBPROCESS_ENV}


def self_capture(vkcube_path: str, output: Path, timeout: int = 60) -> Path:
"""Run ``rdc capture`` against *vkcube_path* and return the .rdc path."""
Expand All @@ -58,6 +68,7 @@ def self_capture(vkcube_path: str, output: Path, timeout: int = 60) -> Path:
capture_output=True,
text=True,
timeout=timeout,
env=_subprocess_env(),
)
if r.returncode != 0:
raise RuntimeError(f"self_capture failed (exit {r.returncode}):\n{r.stderr}")
Expand All @@ -84,6 +95,7 @@ def rdc(
capture_output=True,
text=True,
timeout=timeout,
env=_subprocess_env(),
)


Expand Down
13 changes: 13 additions & 0 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@
from rdc.daemon_server import DaemonState


@pytest.fixture(autouse=True)
def _isolate_data_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Isolate every unit test's rdc data dir to a per-test tmp directory.

Sets ``RDC_DATA_DIR`` (so any subprocess inherits the override) and patches
``rdc._platform.data_dir`` (so in-process callers resolve the same path),
guaranteeing tests never read or write the developer's real ``~/.rdc``.
"""
data = tmp_path / ".rdc"
monkeypatch.setenv("RDC_DATA_DIR", str(data))
monkeypatch.setattr("rdc._platform.data_dir", lambda: data)


def rpc_request(
method: str,
params: dict[str, Any] | None = None,
Expand Down
5 changes: 0 additions & 5 deletions tests/unit/test_android_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,6 @@
)


@pytest.fixture(autouse=True)
def _isolate_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr("rdc._platform.data_dir", lambda: tmp_path / ".rdc")


def _mock_rd_android(
monkeypatch: pytest.MonkeyPatch,
devices: list[str] | None = None,
Expand Down
6 changes: 0 additions & 6 deletions tests/unit/test_capture_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from __future__ import annotations

import time
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock

Expand All @@ -19,11 +18,6 @@
from rdc.target_state import TargetControlState, save_target_state


@pytest.fixture(autouse=True)
def _isolate_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr("rdc._platform.data_dir", lambda: tmp_path / ".rdc")


def _make_mock_tc(
*,
connected: bool = True,
Expand Down
1 change: 0 additions & 1 deletion tests/unit/test_cli_session_flag.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ def fake_status() -> tuple[bool, object]:
def test_two_sessions_isolated(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Two named sessions return independent data from their respective files."""
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setattr("rdc._platform.data_dir", lambda: tmp_path / ".rdc")
monkeypatch.delenv("RDC_SESSION", raising=False)
monkeypatch.setattr("rdc.services.session_service._renderdoc_available", lambda: False)
mock_proc = MagicMock()
Expand Down
6 changes: 0 additions & 6 deletions tests/unit/test_keep_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

from pathlib import Path
from typing import Any
from unittest.mock import MagicMock

Expand All @@ -14,11 +13,6 @@
from rdc.remote_state import RemoteServerState, save_remote_state


@pytest.fixture(autouse=True)
def _isolate_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr("rdc._platform.data_dir", lambda: tmp_path / ".rdc")


def _save_state() -> None:
save_remote_state(RemoteServerState(host="192.168.1.10", port=39920, connected_at=1000.0))

Expand Down
63 changes: 62 additions & 1 deletion tests/unit/test_platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,77 @@

class TestDataDir:
def test_returns_home_dot_rdc(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""TP-W1-001: Unix data_dir is ~/.rdc."""
"""TP-W1-001: Unix data_dir is ~/.rdc when no override is set."""
monkeypatch.delenv("RDC_DATA_DIR", raising=False)
monkeypatch.setattr("rdc._platform.Path.home", staticmethod(lambda: tmp_path))
assert data_dir() == tmp_path / ".rdc"

def test_no_side_effects(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""TP-W1-002: data_dir does not create the directory."""
monkeypatch.delenv("RDC_DATA_DIR", raising=False)
monkeypatch.setattr("rdc._platform.Path.home", staticmethod(lambda: tmp_path))
result = data_dir()
assert not result.exists()

def test_env_override_returns_custom_dir(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""RDC_DATA_DIR override wins over the home-based default."""
custom = tmp_path / "custom-data"
monkeypatch.setenv("RDC_DATA_DIR", str(custom))
# Even with a different home, the override must take precedence.
monkeypatch.setattr("rdc._platform.Path.home", staticmethod(lambda: tmp_path / "elsewhere"))
assert data_dir() == custom

def test_env_override_ignored_when_empty(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""An empty RDC_DATA_DIR falls back to the home-based default."""
monkeypatch.setenv("RDC_DATA_DIR", "")
monkeypatch.setattr("rdc._platform.Path.home", staticmethod(lambda: tmp_path))
assert data_dir() == tmp_path / ".rdc"

def test_env_override_isolates_session_roundtrip(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""With RDC_DATA_DIR set, a session save/load round-trip writes nothing under home.

Regression: prior to the override seam, session_state always resolved
``Path.home()/.rdc`` with no way to redirect a subprocess; this asserts
the override fully redirects both the write and the read.
"""
from rdc.session_state import SessionState, load_session, save_session

fake_home = tmp_path / "home"
fake_home.mkdir()
data = tmp_path / "isolated"
# Restore the genuine data_dir so the env seam (not the conftest patch)
# is what redirects session_state's reads and writes.
monkeypatch.setattr("rdc._platform.data_dir", data_dir)
monkeypatch.setenv("RDC_DATA_DIR", str(data))
monkeypatch.setattr("rdc._platform.Path.home", staticmethod(lambda: fake_home))
monkeypatch.delenv("RDC_SESSION", raising=False)

state = SessionState(
capture="/tmp/x.rdc",
current_eid=7,
opened_at="2026-01-01T00:00:00+00:00",
host="127.0.0.1",
port=4321,
token="tok",
pid=4242,
)
save_session(state)

loaded = load_session()
assert loaded is not None
assert loaded.capture == "/tmp/x.rdc"
assert loaded.current_eid == 7
assert (data / "sessions" / "default.json").exists()
# Nothing must have leaked under the faked home directory.
assert not list(fake_home.rglob("*.json"))
assert not (fake_home / ".rdc").exists()


# ── Group B: terminate_process() ─────────────────────────────────────

Expand Down
5 changes: 0 additions & 5 deletions tests/unit/test_remote_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,6 @@
from rdc.remote_state import RemoteServerState, save_remote_state


@pytest.fixture(autouse=True)
def _isolate_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr("rdc._platform.data_dir", lambda: tmp_path / ".rdc")


def _mock_rd(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
"""Provide a mock renderdoc module and patch find_renderdoc."""
rd = MagicMock()
Expand Down
6 changes: 5 additions & 1 deletion tests/unit/test_remote_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,12 @@ def test_parsed_localhost_keys_on_ipv4(self) -> None:
host, port = parse_url("localhost:39920")
assert (host, port) == ("127.0.0.1", 39920)
path = _state_path(host, port)
# The state-file key must use the normalized IPv4, never "localhost".
# (Assert on the relative key, not the absolute path, since the isolated
# data dir lives under a per-test tmp directory whose name may itself
# contain "localhost".)
assert path.name == "127.0.0.1_39920.json"
assert "localhost" not in str(path)
assert "localhost" not in path.parent.name


class TestWarnIfPublic:
Expand Down
6 changes: 0 additions & 6 deletions tests/unit/test_remote_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import json
import socket
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock

Expand All @@ -19,11 +18,6 @@
)


@pytest.fixture(autouse=True)
def _isolate_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr("rdc._platform.data_dir", lambda: tmp_path / ".rdc")


def _stderr_spy(monkeypatch: pytest.MonkeyPatch) -> list[str]:
lines: list[str] = []
orig = click.echo
Expand Down
5 changes: 0 additions & 5 deletions tests/unit/test_remote_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,6 @@
_SAMPLE = RemoteServerState(host="192.168.1.10", port=39920, connected_at=1700000000.0)


@pytest.fixture(autouse=True)
def _isolate_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr("rdc._platform.data_dir", lambda: tmp_path / ".rdc")


def test_save_and_load_round_trip() -> None:
save_remote_state(_SAMPLE)
loaded = load_remote_state("192.168.1.10", 39920)
Expand Down
Loading
Loading