From 9e0f045ccbdc460092ebbc4e13b4fc8ab17d7e5b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:31:19 -0400 Subject: [PATCH] Stop the test suite exiting 0 after 10% of its tests `pytest src/tests` died at 123 of 1170 tests with exit code 0 -- no summary, no coverage report, and a shell that saw success. Four tests reach code paths that call os._exit(0), which skips interpreter finalization and takes pytest with it. The os._exit is deliberate and stays: some builds with native extensions intermittently SIGSEGV during teardown after an otherwise clean shutdown, which is why _exit_as_main_process exists. But that reasoning applies to the real process exit, not to a function under test. Adds _terminate(), which raises SystemExit when PYTEST_CURRENT_TEST is set and calls os._exit otherwise, and routes the three in-process exit paths through it. This restores a contract the tests already assumed: test_auto_sync_triggers_post asserts `pytest.raises(SystemExit)`. It was written when display_menu raised, and the later switch to os._exit did not fail that test -- it killed the runner instead, which is why nobody noticed. The suite now runs to completion: 1160 passed, 15 failed, 16 skipped, and it exits 1 rather than claiming success. Those 15 were always failing; they were simply never reached. Five of them were mine, and this is the uncomfortable part: the gen_version argument added in 2aed95a broke more FakePasswordGenerator doubles than I found, because the earlier regression run stopped before reaching them and I concluded "no new failures" from a truncated result. All 20 remaining doubles now take **_, so they absorb gen_version and anything added later rather than needing a sweep each time the real signature grows. The other 10 are pre-existing and unrelated -- legacy-migration and checksum-backup failures, an index0.py int-parsing bug (`invalid literal for int() with base 10: 'temp'`), and one that needs a TTY this environment lacks. Determinism gate still green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u --- src/main.py | 27 ++++++++++++++++--- src/tests/test_add_tags_from_retrieve.py | 2 +- src/tests/test_cli_agent_mode.py | 12 ++++----- src/tests/test_cli_clipboard_flag.py | 2 +- src/tests/test_cli_doc_examples.py | 2 +- src/tests/test_cli_subcommands.py | 2 +- src/tests/test_edit_tags_from_retrieve.py | 2 +- src/tests/test_password_notes_display.py | 2 +- src/tests/test_secret_mode.py | 2 +- src/tests/test_tui_v2_action_matrix.py | 2 +- .../test_tui_v2_exploratory_walkthrough.py | 2 +- src/tests/test_tui_v2_kb_scale_stress.py | 2 +- src/tests/test_tui_v2_textual_interactions.py | 2 +- src/tests/test_tui_v3_parity.py | 2 +- src/tests/test_tui_v3_smoke.py | 2 +- src/tests/test_typer_cli.py | 2 +- 16 files changed, 44 insertions(+), 23 deletions(-) diff --git a/src/main.py b/src/main.py index 52994955..92cdb668 100644 --- a/src/main.py +++ b/src/main.py @@ -251,7 +251,7 @@ def handle_remove_fingerprint(password_manager: PasswordManager): password_manager.is_dirty = False getattr(password_manager, "cleanup", lambda: None)() print(colored("All seed profiles removed. Exiting.", "yellow")) - os._exit(0) + _terminate(0) if password_manager.fingerprint_manager.remove_fingerprint( selected_fingerprint, _cleanup_and_exit @@ -1517,7 +1517,7 @@ def display_menu( print(colored("Exiting the program.", "green")) getattr(password_manager, "cleanup", lambda: None)() _safe_close_client_pool(password_manager) - os._exit(0) + _terminate(0) try: if choice == "1": while True: @@ -1859,7 +1859,7 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in except Exception as exc: logging.error(f"Error during shutdown: {exc}") print(colored(f"Error during shutdown: {exc}", "red")) - os._exit(0) + _terminate(0) signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) @@ -1904,6 +1904,27 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in return 0 +def _terminate(exit_code: int = 0) -> "NoReturn": + """End the process, or raise SystemExit when running under pytest. + + ``os._exit`` skips interpreter finalization, which is deliberate: some + builds with native extensions intermittently SIGSEGV during teardown after + an otherwise clean shutdown (see :func:`_exit_as_main_process`). + + It also bypasses pytest. A test that reaches one of these paths kills the + runner mid-suite with status 0 -- no summary, no coverage report, and a + shell that sees success. That is how `pytest src/tests` came to stop at + roughly 10% of the suite while still exiting 0. + + Under pytest we therefore raise SystemExit, which is what the affected + tests already expect (``test_auto_sync_triggers_post`` asserts + ``pytest.raises(SystemExit)``). Production behaviour is unchanged. + """ + if os.environ.get("PYTEST_CURRENT_TEST"): + raise SystemExit(exit_code) + os._exit(exit_code) + + def _exit_as_main_process() -> "NoReturn": """Execute ``main`` and terminate the process without Python finalizers. diff --git a/src/tests/test_add_tags_from_retrieve.py b/src/tests/test_add_tags_from_retrieve.py index d9ed8355..cc6e017b 100644 --- a/src/tests/test_add_tags_from_retrieve.py +++ b/src/tests/test_add_tags_from_retrieve.py @@ -14,7 +14,7 @@ from seedpass.core.config_manager import ConfigManager class FakePasswordGenerator: - def generate_password(self, length: int, index: int) -> str: # noqa: D401 + def generate_password(self, length: int, index: int, **_) -> str: # noqa: D401 return "pw" diff --git a/src/tests/test_cli_agent_mode.py b/src/tests/test_cli_agent_mode.py index bb72d808..0bfff13a 100644 --- a/src/tests/test_cli_agent_mode.py +++ b/src/tests/test_cli_agent_mode.py @@ -218,7 +218,7 @@ def test_agent_get_password_json(monkeypatch): def retrieve_entry(self, _i): return {"type": EntryType.PASSWORD.value, "label": "Example", "length": 12} - def generate_password(self, _length, _index): + def generate_password(self, _length, _index, **_): return "secret-pass" monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService) @@ -464,7 +464,7 @@ def test_agent_get_safe_output_masks_by_default(monkeypatch): def retrieve_entry(self, _i): return {"type": EntryType.PASSWORD.value, "label": "Example", "length": 12} - def generate_password(self, _length, _index): + def generate_password(self, _length, _index, **_): return "secret-pass" monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService) @@ -510,7 +510,7 @@ def test_agent_token_issue_and_use_limit(monkeypatch, tmp_path): def retrieve_entry(self, _i): return {"type": EntryType.PASSWORD.value, "label": "Example", "length": 12} - def generate_password(self, _length, _index): + def generate_password(self, _length, _index, **_): return "secret-pass" monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService) @@ -860,7 +860,7 @@ def test_agent_token_denied_when_identity_revoked(monkeypatch, tmp_path): def retrieve_entry(self, _i): return {"type": EntryType.PASSWORD.value, "label": "Example", "length": 12} - def generate_password(self, _length, _index): + def generate_password(self, _length, _index, **_): return "secret-pass" monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService) @@ -948,7 +948,7 @@ def test_agent_token_denied_when_identity_missing(monkeypatch, tmp_path): def retrieve_entry(self, _i): return {"type": EntryType.PASSWORD.value, "label": "Example", "length": 12} - def generate_password(self, _length, _index): + def generate_password(self, _length, _index, **_): return "secret-pass" monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService) @@ -1598,7 +1598,7 @@ def test_agent_get_keyring_defaults_account_to_fingerprint(monkeypatch): def retrieve_entry(self, _i): return {"type": EntryType.PASSWORD.value, "label": "Example", "length": 12} - def generate_password(self, _length, _index): + def generate_password(self, _length, _index, **_): return "secret-pass" monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService) diff --git a/src/tests/test_cli_clipboard_flag.py b/src/tests/test_cli_clipboard_flag.py index a51a5498..1229d6da 100644 --- a/src/tests/test_cli_clipboard_flag.py +++ b/src/tests/test_cli_clipboard_flag.py @@ -15,7 +15,7 @@ def _stub_service(ctx, raise_error=True): def retrieve_entry(self, idx): return {"type": EntryType.PASSWORD.value, "length": 12} - def generate_password(self, length, index): + def generate_password(self, length, index, **_): if raise_error and not ctx.obj.get("no_clipboard"): raise ClipboardUnavailableError("missing") return "pwd" diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py index 52a208b9..64e702ec 100644 --- a/src/tests/test_cli_doc_examples.py +++ b/src/tests/test_cli_doc_examples.py @@ -38,7 +38,7 @@ class DummyPM: export_totp_entries=lambda seed: {"entries": []}, ) self.password_generator = SimpleNamespace( - generate_password=lambda length, index=None: "pw" + generate_password=lambda length, index=None, **_: "pw" ) self.parent_seed = "seed" self.handle_display_totp_codes = lambda: None diff --git a/src/tests/test_cli_subcommands.py b/src/tests/test_cli_subcommands.py index 98796c01..087e4d21 100644 --- a/src/tests/test_cli_subcommands.py +++ b/src/tests/test_cli_subcommands.py @@ -14,7 +14,7 @@ def make_pm(search_results, entry=None, totp_code="123456"): retrieve_entry=lambda idx: entry, get_totp_code=lambda idx, seed: totp_code, ) - pg = SimpleNamespace(generate_password=lambda l, i: "pw") + pg = SimpleNamespace(generate_password=lambda l, i, **_: "pw") pm = SimpleNamespace( entry_manager=entry_mgr, password_generator=pg, diff --git a/src/tests/test_edit_tags_from_retrieve.py b/src/tests/test_edit_tags_from_retrieve.py index 2cf38ddf..c0635c8c 100644 --- a/src/tests/test_edit_tags_from_retrieve.py +++ b/src/tests/test_edit_tags_from_retrieve.py @@ -6,7 +6,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) class FakePasswordGenerator: - def generate_password(self, length: int, index: int) -> str: # noqa: D401 + def generate_password(self, length: int, index: int, **_) -> str: # noqa: D401 return "pw" diff --git a/src/tests/test_password_notes_display.py b/src/tests/test_password_notes_display.py index f182d0ae..e9509ec5 100644 --- a/src/tests/test_password_notes_display.py +++ b/src/tests/test_password_notes_display.py @@ -27,7 +27,7 @@ def test_password_notes_shown(monkeypatch, capsys): pm.vault = vault pm.entry_manager = entry_mgr pm.backup_manager = backup_mgr - pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw") + pm.password_generator = SimpleNamespace(generate_password=lambda l, i, **_: "pw") pm.parent_seed = TEST_SEED pm.nostr_client = SimpleNamespace() pm.fingerprint_dir = tmp_path diff --git a/src/tests/test_secret_mode.py b/src/tests/test_secret_mode.py index be5222d3..aa75aec5 100644 --- a/src/tests/test_secret_mode.py +++ b/src/tests/test_secret_mode.py @@ -25,7 +25,7 @@ def setup_pm(tmp_path): pm.vault = vault pm.entry_manager = entry_mgr pm.backup_manager = backup_mgr - pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw") + pm.password_generator = SimpleNamespace(generate_password=lambda l, i, **_: "pw") pm.parent_seed = TEST_SEED pm.nostr_client = SimpleNamespace() pm.fingerprint_dir = tmp_path diff --git a/src/tests/test_tui_v2_action_matrix.py b/src/tests/test_tui_v2_action_matrix.py index df70e694..f094215b 100644 --- a/src/tests/test_tui_v2_action_matrix.py +++ b/src/tests/test_tui_v2_action_matrix.py @@ -287,7 +287,7 @@ class MatrixEntryService: if value is not None: entry[key] = value - def generate_password(self, length: int, entry_id: int) -> str: + def generate_password(self, length: int, entry_id: int, **_) -> str: return f"pw-{entry_id}-{length}" def get_seed_phrase(self, entry_id: int) -> str: diff --git a/src/tests/test_tui_v2_exploratory_walkthrough.py b/src/tests/test_tui_v2_exploratory_walkthrough.py index 2e739de6..f80d7b6e 100644 --- a/src/tests/test_tui_v2_exploratory_walkthrough.py +++ b/src/tests/test_tui_v2_exploratory_walkthrough.py @@ -191,7 +191,7 @@ class FakeEntryService: if v is not None: e[k] = v - def generate_password(self, length, eid): + def generate_password(self, length, eid, **_): return f"pw-{eid}-{length}" def get_seed_phrase(self, eid): diff --git a/src/tests/test_tui_v2_kb_scale_stress.py b/src/tests/test_tui_v2_kb_scale_stress.py index f98d6292..b0ffb634 100644 --- a/src/tests/test_tui_v2_kb_scale_stress.py +++ b/src/tests/test_tui_v2_kb_scale_stress.py @@ -75,7 +75,7 @@ class LargeKbService: _ = entry_id return [] - def generate_password(self, length: int, entry_id: int) -> str: + def generate_password(self, length: int, entry_id: int, **_) -> str: return f"pw-{entry_id}-{length}" diff --git a/src/tests/test_tui_v2_textual_interactions.py b/src/tests/test_tui_v2_textual_interactions.py index 5fb4ad31..1b7d0917 100644 --- a/src/tests/test_tui_v2_textual_interactions.py +++ b/src/tests/test_tui_v2_textual_interactions.py @@ -254,7 +254,7 @@ class FakeEntryService: continue entry[key] = value - def generate_password(self, length: int, entry_id: int) -> str: + def generate_password(self, length: int, entry_id: int, **_) -> str: return f"pw-{entry_id}-{length}" def get_seed_phrase(self, entry_id: int) -> str: diff --git a/src/tests/test_tui_v3_parity.py b/src/tests/test_tui_v3_parity.py index 76feb4ed..e1269c6d 100644 --- a/src/tests/test_tui_v3_parity.py +++ b/src/tests/test_tui_v3_parity.py @@ -56,7 +56,7 @@ class MockEntryService: def retrieve_entry(self, eid): return self.entries.get(eid) - def generate_password(self, length, eid): + def generate_password(self, length, eid, **_): return "gen-pwd" def get_ssh_key_pair(self, eid): diff --git a/src/tests/test_tui_v3_smoke.py b/src/tests/test_tui_v3_smoke.py index 7b3a3459..64b132db 100644 --- a/src/tests/test_tui_v3_smoke.py +++ b/src/tests/test_tui_v3_smoke.py @@ -84,7 +84,7 @@ class V3EntryService: def retrieve_entry(self, entry_id: int): return dict(self._entries.get(int(entry_id), {})) - def generate_password(self, length: int, entry_id: int) -> str: + def generate_password(self, length: int, entry_id: int, **_) -> str: return f"pw-{entry_id}-{length}" def archive_entry(self, entry_id: int) -> None: diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index 535d9125..9295069e 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -93,7 +93,7 @@ def test_entry_get_password(monkeypatch): retrieve_entry=lambda i: entry, get_totp_code=lambda i, s: "", ), - password_generator=SimpleNamespace(generate_password=lambda l, i: "pw"), + password_generator=SimpleNamespace(generate_password=lambda l, i, **_: "pw"), parent_seed="seed", select_fingerprint=lambda fp: None, )