|
| 1 | +import atexit |
1 | 2 | import logging |
2 | 3 | import os |
| 4 | +import shutil |
3 | 5 | import sys |
4 | 6 | import tempfile |
5 | | -from typing import Any |
6 | 7 | from unittest.mock import AsyncMock, MagicMock |
7 | 8 |
|
8 | 9 | import pytest |
|
27 | 28 | sys.path.insert(0, _tests_root) |
28 | 29 |
|
29 | 30 |
|
| 31 | +# Import-time temp dirs, so the mock module is complete before anything imports |
| 32 | +# main. The settings/runtime pair is replaced per test by `_reset_decky_mock_paths`; |
| 33 | +# the log dir lives for the whole process. |
| 34 | +_process_settings_dir = tempfile.mkdtemp() |
| 35 | +_process_runtime_dir = tempfile.mkdtemp() |
| 36 | +_process_log_dir = tempfile.mkdtemp() |
| 37 | + |
| 38 | + |
| 39 | +@atexit.register |
| 40 | +def _cleanup_process_temp_dirs() -> None: |
| 41 | + """Drop the import-time temp dirs — ``mkdtemp`` never cleans up after itself. |
| 42 | +
|
| 43 | + Deliberately ``atexit`` rather than a session-scoped fixture: a fixture |
| 44 | + only tears down once at least one test is collected, so a run that |
| 45 | + collects nothing — pointing pytest at a directory that holds only |
| 46 | + fixtures or vectors — would leak all three. atexit also stays correct |
| 47 | + if this module is ever executed more than once, since each execution |
| 48 | + releases exactly what it created. |
| 49 | + """ |
| 50 | + for path in (_process_settings_dir, _process_runtime_dir, _process_log_dir): |
| 51 | + shutil.rmtree(path, ignore_errors=True) |
| 52 | + |
| 53 | + |
30 | 54 | # Create mock decky module before any imports of main |
31 | 55 | mock_decky = MagicMock() |
32 | 56 | mock_decky.DECKY_PLUGIN_DIR = _project_root |
33 | | -mock_decky.DECKY_PLUGIN_SETTINGS_DIR = tempfile.mkdtemp() |
34 | | -mock_decky.DECKY_PLUGIN_RUNTIME_DIR = tempfile.mkdtemp() |
35 | | -mock_decky.DECKY_PLUGIN_LOG_DIR = tempfile.mkdtemp() |
| 57 | +mock_decky.DECKY_PLUGIN_SETTINGS_DIR = _process_settings_dir |
| 58 | +mock_decky.DECKY_PLUGIN_RUNTIME_DIR = _process_runtime_dir |
| 59 | +mock_decky.DECKY_PLUGIN_LOG_DIR = _process_log_dir |
36 | 60 | mock_decky.DECKY_USER_HOME = os.path.expanduser("~") |
37 | 61 | mock_decky.logger = logging.getLogger("test_romm") |
38 | 62 | mock_decky.emit = AsyncMock() |
39 | 63 |
|
40 | 64 | sys.modules["decky"] = mock_decky |
41 | 65 |
|
42 | 66 |
|
43 | | -def _no_retry(fn, *a, **kw): |
44 | | - """Pass-through Retry side_effect: invoke the wrapped callable once, no backoff.""" |
45 | | - return fn(*a, **kw) |
46 | | - |
47 | | - |
48 | | -def _make_retry(): |
49 | | - """Build a Retry ``MagicMock`` that runs ``with_retry`` callables exactly once |
50 | | - and reports every exception as non-retryable. Used everywhere services |
51 | | - take a ``Retry`` Protocol injection in tests.""" |
52 | | - retry = MagicMock() |
53 | | - retry.with_retry.side_effect = _no_retry |
54 | | - retry.is_retryable.return_value = False |
55 | | - return retry |
56 | | - |
57 | | - |
58 | | -def _make_testable_plugin(): |
59 | | - """Return a TestablePlugin instance with test-only attributes declared. |
60 | | -
|
61 | | - Pre-populates ``_migration_service`` with a non-pending MagicMock so the |
62 | | - ``@migration_blocked`` decorator passes through (it requires the service and |
63 | | - raises RuntimeError if it is unwired) in tests that don't otherwise wire |
64 | | - migration state. Tests that exercise the block can override |
65 | | - ``is_retrodeck_migration_pending`` per-test. |
66 | | -
|
67 | | - Also pre-wires a no-op ``_debug_logger`` so any service that consumes |
68 | | - ``Plugin._log_debug`` (which forwards through ``_debug_logger``) works |
69 | | - out of the box. Tests that want to assert on debug-log behaviour can |
70 | | - override ``_debug_logger`` after construction (e.g. with the real |
71 | | - ``SettingsAwareDebugLogger`` bound to a settings dict they control). |
72 | | - """ |
73 | | - # Import here to ensure decky mock is already installed |
74 | | - from main import Plugin |
75 | | - |
76 | | - class TestablePlugin(Plugin): |
77 | | - """Plugin subclass that declares test-only attributes for type safety. |
78 | | -
|
79 | | - Genuinely test-fixture-only attributes live here: ``_fake_api``, |
80 | | - ``_resolve_system``, ``_save_settings``, plus the Unit-of-Work |
81 | | - handles tests seed and assert against (``_uow``, ``_uow_factory``) |
82 | | - and the per-test ``_tmp_path`` scratch dir. Test-fixture handles |
83 | | - shared with production wiring (``_state``, ``_http_adapter``, ...) |
84 | | - are declared on ``Plugin`` itself as ``Any``-typed annotation slots |
85 | | - so test-only construction paths type-check uniformly. |
86 | | - ``_save_settings`` is a test-only handle for the settings dict tests |
87 | | - thread into ``SaveService`` / ``PlaytimeService``; production threads |
88 | | - its settings store as ``self.settings``, never under this name. |
89 | | - """ |
90 | | - |
91 | | - _fake_api: Any |
92 | | - _resolve_system: Any |
93 | | - _save_settings: Any |
94 | | - _uow: Any |
95 | | - _uow_factory: Any |
96 | | - _tmp_path: Any |
97 | | - _core_info: Any |
98 | | - _platform_core_reader: Any |
99 | | - _active_core: Any |
100 | | - _m3u_supported: Any |
101 | | - _renderer_rss: Any |
102 | | - _renderer_gc: Any |
103 | | - |
104 | | - instance = TestablePlugin() |
105 | | - instance._migration_service = MagicMock() |
106 | | - instance._migration_service.is_retrodeck_migration_pending.return_value = False |
107 | | - instance._debug_logger = lambda msg: None |
108 | | - return instance |
109 | | - |
110 | | - |
111 | 67 | @pytest.fixture |
112 | 68 | def fake_romm_api(): |
113 | 69 | """Function-scoped ``FakeRommApi`` instance. |
@@ -141,10 +97,21 @@ def _reset_decky_mock_paths(): |
141 | 97 |
|
142 | 98 | Fresh ``DECKY_PLUGIN_SETTINGS_DIR`` and ``DECKY_PLUGIN_RUNTIME_DIR`` |
143 | 99 | per test prevents cross-test pollution from persistence-touching |
144 | | - tests. |
| 100 | + tests. Both are removed again on teardown — ``mkdtemp`` leaves the |
| 101 | + directory behind by design, and two leaked dirs per test across a |
| 102 | + suite this size exhaust the tmpfs inode table in days, not months. |
| 103 | +
|
| 104 | + They deliberately do not live under ``tmp_path``: adapter tests assert |
| 105 | + on the exact contents of their ``tmp_path`` (``listdir(...) == []``), |
| 106 | + and ``tests/contract`` already owns ``tmp_path/settings`` and |
| 107 | + ``tmp_path/runtime``. |
145 | 108 | """ |
146 | 109 | mock_decky.DECKY_USER_HOME = os.path.expanduser("~") |
147 | 110 | mock_decky.DECKY_PLUGIN_DIR = _project_root |
148 | | - mock_decky.DECKY_PLUGIN_SETTINGS_DIR = tempfile.mkdtemp() |
149 | | - mock_decky.DECKY_PLUGIN_RUNTIME_DIR = tempfile.mkdtemp() |
| 111 | + settings_dir = tempfile.mkdtemp() |
| 112 | + runtime_dir = tempfile.mkdtemp() |
| 113 | + mock_decky.DECKY_PLUGIN_SETTINGS_DIR = settings_dir |
| 114 | + mock_decky.DECKY_PLUGIN_RUNTIME_DIR = runtime_dir |
150 | 115 | yield |
| 116 | + shutil.rmtree(settings_dir, ignore_errors=True) |
| 117 | + shutil.rmtree(runtime_dir, ignore_errors=True) |
0 commit comments