diff --git a/changelog/10562.improvement.rst b/changelog/10562.improvement.rst new file mode 100644 index 00000000000..46f3d95ba24 --- /dev/null +++ b/changelog/10562.improvement.rst @@ -0,0 +1 @@ +``--stepwise`` now continues from the test that was running when the session was interrupted, for example by quitting the debugger with ``q``, instead of starting over. diff --git a/src/_pytest/stepwise.py b/src/_pytest/stepwise.py index c14b6fc250b..1a761793c17 100644 --- a/src/_pytest/stepwise.py +++ b/src/_pytest/stepwise.py @@ -9,6 +9,7 @@ from _pytest import nodes from _pytest.cacheprovider import Cache from _pytest.config import Config +from _pytest.config import ExitCode from _pytest.config.argparsing import Parser from _pytest.main import Session from _pytest.nodeid import NodeId @@ -107,6 +108,8 @@ def __init__(self, config: Config) -> None: self.skip: bool = config.getoption("stepwise_skip") self.reset: bool = config.getoption("stepwise_reset") self.cached_info = self._load_cached_info() + # The test currently running, if any. + self.running_test: NodeId | None = None def _load_cached_info(self) -> StepwiseCacheInfo: cached_dict: dict[str, Any] | None = self.cache.get(STEPWISE_CACHE_DIR, None) @@ -197,16 +200,27 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: if report.id == self.cached_info.last_failed: self.cached_info.last_failed = None + def pytest_runtest_logstart(self, nodeid: str) -> None: + self.running_test = NodeId.parse(nodeid) + + def pytest_runtest_logfinish(self) -> None: + self.running_test = None + def pytest_report_collectionfinish(self) -> list[str] | None: if self.config.get_verbosity() >= 0 and self.report_status: return [f"stepwise: {x}" for x in self.report_status] return None - def pytest_sessionfinish(self) -> None: + def pytest_sessionfinish(self, exitstatus: int | ExitCode) -> None: if hasattr(self.config, "workerinput"): # Do not update cache if this process is a xdist worker to prevent # race conditions (#10641). return + if exitstatus == ExitCode.INTERRUPTED and self.running_test is not None: + # The session was interrupted while a test was running, for example + # by quitting the debugger, so that test has no failed report: + # continue from it next time (#10562). + self.cached_info.last_failed = self.running_test self.cached_info.update_date_to_now() last_failed = self.cached_info.last_failed self.cache.set( diff --git a/testing/test_stepwise.py b/testing/test_stepwise.py index d2ad3bae500..3d6156369f9 100644 --- a/testing/test_stepwise.py +++ b/testing/test_stepwise.py @@ -543,3 +543,39 @@ def test_1(): pass "* 1 passed in *", ] ) + + +def test_continue_from_test_interrupted_by_quitting_debugger( + pytester: Pytester, monkeypatch: MonkeyPatch +) -> None: + """Quitting the debugger interrupts the session without a failed report, + --stepwise should continue from that test next time (#10562).""" + pytester.makepyfile( + """ + import os + import pytest + + def test_1(): pass + + def test_2(): + if os.environ.get("QUIT_DEBUGGER"): + # What pytest does when "q" is used in the debugger. + pytest.exit("Quitting debugger") + + def test_3(): pass + """ + ) + monkeypatch.setenv("QUIT_DEBUGGER", "1") + result = pytester.runpytest("--stepwise", "-v") + result.stdout.fnmatch_lines(["*::test_1 *PASSED*", "*Quitting debugger*"]) + + monkeypatch.delenv("QUIT_DEBUGGER") + result = pytester.runpytest("--stepwise", "-v") + result.stdout.fnmatch_lines( + [ + "stepwise: skipping 1 already passed items (cache from *, use --sw-reset to discard).", + "*::test_2 *PASSED*", + "*::test_3 *PASSED*", + "* 2 passed, 1 deselected in *", + ] + )