|
10 | 10 | import textwrap
|
11 | 11 | from types import SimpleNamespace
|
12 | 12 | from typing import cast
|
| 13 | +from typing import Literal |
13 | 14 | from typing import NamedTuple
|
| 15 | +from unittest.mock import Mock |
| 16 | +from unittest.mock import patch |
14 | 17 |
|
15 | 18 | import pluggy
|
16 | 19 |
|
|
30 | 33 | from _pytest.terminal import _get_raw_skip_reason
|
31 | 34 | from _pytest.terminal import _plugin_nameversions
|
32 | 35 | from _pytest.terminal import getreportopt
|
| 36 | +from _pytest.terminal import TerminalProgressPlugin |
33 | 37 | from _pytest.terminal import TerminalReporter
|
34 | 38 | import pytest
|
35 | 39 |
|
@@ -3297,3 +3301,116 @@ def test_x(a):
|
3297 | 3301 | r".*test_foo.py::test_x\[a::b/\] .*FAILED.*",
|
3298 | 3302 | ]
|
3299 | 3303 | )
|
| 3304 | + |
| 3305 | + |
| 3306 | +class TestTerminalProgressPlugin: |
| 3307 | + """Tests for the TerminalProgressPlugin.""" |
| 3308 | + |
| 3309 | + @pytest.fixture |
| 3310 | + def mock_file(self) -> StringIO: |
| 3311 | + return StringIO() |
| 3312 | + |
| 3313 | + @pytest.fixture |
| 3314 | + def mock_tr(self, mock_file: StringIO) -> pytest.TerminalReporter: |
| 3315 | + tr = Mock(spec=pytest.TerminalReporter) |
| 3316 | + |
| 3317 | + def write_raw(s: str, *, flush: bool = False) -> None: |
| 3318 | + mock_file.write(s) |
| 3319 | + |
| 3320 | + tr.write_raw = write_raw |
| 3321 | + tr._progress_nodeids_reported = set() |
| 3322 | + return tr |
| 3323 | + |
| 3324 | + def test_plugin_registration(self, pytester: pytest.Pytester) -> None: |
| 3325 | + """Test that the plugin is registered correctly on TTY output.""" |
| 3326 | + # The plugin module should be registered as a default plugin. |
| 3327 | + with patch.object(sys.stdout, "isatty", return_value=True): |
| 3328 | + config = pytester.parseconfigure() |
| 3329 | + plugin = config.pluginmanager.get_plugin("terminalprogress") |
| 3330 | + assert plugin is not None |
| 3331 | + |
| 3332 | + def test_disabled_for_non_tty(self, pytester: pytest.Pytester) -> None: |
| 3333 | + """Test that plugin is disabled for non-TTY output.""" |
| 3334 | + with patch.object(sys.stdout, "isatty", return_value=False): |
| 3335 | + config = pytester.parseconfigure() |
| 3336 | + plugin = config.pluginmanager.get_plugin("terminalprogress") |
| 3337 | + assert plugin is None |
| 3338 | + |
| 3339 | + @pytest.mark.parametrize( |
| 3340 | + ["state", "progress", "expected"], |
| 3341 | + [ |
| 3342 | + ("indeterminate", None, "\x1b]9;4;3;\x1b\\"), |
| 3343 | + ("normal", 50, "\x1b]9;4;1;50\x1b\\"), |
| 3344 | + ("error", 75, "\x1b]9;4;2;75\x1b\\"), |
| 3345 | + ("paused", None, "\x1b]9;4;4;\x1b\\"), |
| 3346 | + ("paused", 80, "\x1b]9;4;4;80\x1b\\"), |
| 3347 | + ("remove", None, "\x1b]9;4;0;\x1b\\"), |
| 3348 | + ], |
| 3349 | + ) |
| 3350 | + def test_emit_progress_sequences( |
| 3351 | + self, |
| 3352 | + mock_file: StringIO, |
| 3353 | + mock_tr: pytest.TerminalReporter, |
| 3354 | + state: Literal["remove", "normal", "error", "indeterminate", "paused"], |
| 3355 | + progress: int | None, |
| 3356 | + expected: str, |
| 3357 | + ) -> None: |
| 3358 | + """Test that progress sequences are emitted correctly.""" |
| 3359 | + plugin = TerminalProgressPlugin(mock_tr) |
| 3360 | + plugin._emit_progress(state, progress) |
| 3361 | + assert expected in mock_file.getvalue() |
| 3362 | + |
| 3363 | + def test_session_lifecycle( |
| 3364 | + self, mock_file: StringIO, mock_tr: pytest.TerminalReporter |
| 3365 | + ) -> None: |
| 3366 | + """Test progress updates during session lifecycle.""" |
| 3367 | + plugin = TerminalProgressPlugin(mock_tr) |
| 3368 | + |
| 3369 | + session = Mock(spec=pytest.Session) |
| 3370 | + session.testscollected = 3 |
| 3371 | + |
| 3372 | + # Session start - should emit indeterminate progress. |
| 3373 | + plugin.pytest_sessionstart(session) |
| 3374 | + assert "\x1b]9;4;3;\x1b\\" in mock_file.getvalue() |
| 3375 | + mock_file.truncate(0) |
| 3376 | + mock_file.seek(0) |
| 3377 | + |
| 3378 | + # Collection finish - should emit 0% progress. |
| 3379 | + plugin.pytest_collection_finish() |
| 3380 | + assert "\x1b]9;4;1;0\x1b\\" in mock_file.getvalue() |
| 3381 | + mock_file.truncate(0) |
| 3382 | + mock_file.seek(0) |
| 3383 | + |
| 3384 | + # First test - 33% progress. |
| 3385 | + report1 = pytest.TestReport( |
| 3386 | + nodeid="test_1", |
| 3387 | + location=("test.py", 0, "test_1"), |
| 3388 | + when="call", |
| 3389 | + outcome="passed", |
| 3390 | + keywords={}, |
| 3391 | + longrepr=None, |
| 3392 | + ) |
| 3393 | + mock_tr.reported_progress = 1 # type: ignore[misc] |
| 3394 | + plugin.pytest_runtest_logreport(report1) |
| 3395 | + assert "\x1b]9;4;1;33\x1b\\" in mock_file.getvalue() |
| 3396 | + mock_file.truncate(0) |
| 3397 | + mock_file.seek(0) |
| 3398 | + |
| 3399 | + # Second test with failure - 66% in error state. |
| 3400 | + report2 = pytest.TestReport( |
| 3401 | + nodeid="test_2", |
| 3402 | + location=("test.py", 1, "test_2"), |
| 3403 | + when="call", |
| 3404 | + outcome="failed", |
| 3405 | + keywords={}, |
| 3406 | + longrepr=None, |
| 3407 | + ) |
| 3408 | + mock_tr.reported_progress = 2 # type: ignore[misc] |
| 3409 | + plugin.pytest_runtest_logreport(report2) |
| 3410 | + assert "\x1b]9;4;2;66\x1b\\" in mock_file.getvalue() |
| 3411 | + mock_file.truncate(0) |
| 3412 | + mock_file.seek(0) |
| 3413 | + |
| 3414 | + # Session finish - should remove progress. |
| 3415 | + plugin.pytest_sessionfinish() |
| 3416 | + assert "\x1b]9;4;0;\x1b\\" in mock_file.getvalue() |
0 commit comments