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