From 8ca0909fa5c72365a2358c178e168d15230d7920 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Tue, 24 Feb 2026 08:27:56 +0100 Subject: [PATCH] cleanup: remove CFFI deltachat bindings usage, and consolidate test support with rpc-bindings (#872) * cleanup: remove CFFI deltachat bindings usage, and consolidate test support with rpc-bindings major simplification: all chatmail fixtures used in the test are now created inside the cmdeploy plugin, and do not inherit anything from other fixture machineries, let alone the legacy deltachat CFFI ones. also fix that pytest report headers show correct chatmail domains under test --- .gitignore | 2 +- cmdeploy/pyproject.toml | 1 + cmdeploy/src/cmdeploy/cmdeploy.py | 9 +- .../src/cmdeploy/tests/online/benchmark.py | 8 +- .../src/cmdeploy/tests/online/test_1_basic.py | 6 +- .../cmdeploy/tests/online/test_2_deltachat.py | 43 ++--- cmdeploy/src/cmdeploy/tests/plugin.py | 163 +++++++++++------- 7 files changed, 124 insertions(+), 108 deletions(-) diff --git a/.gitignore b/.gitignore index 6e1054d0..c0f40b9b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ __pycache__/ *$py.class *.swp *qr-*.png -chatmail.ini +chatmail*.ini # C extensions diff --git a/cmdeploy/pyproject.toml b/cmdeploy/pyproject.toml index 9f2257b5..e9e88038 100644 --- a/cmdeploy/pyproject.toml +++ b/cmdeploy/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "pytest-xdist", "execnet", "imap_tools", + "deltachat-rpc-client", ] [project.scripts] diff --git a/cmdeploy/src/cmdeploy/cmdeploy.py b/cmdeploy/src/cmdeploy/cmdeploy.py index b0dde2e2..a7ed5fee 100644 --- a/cmdeploy/src/cmdeploy/cmdeploy.py +++ b/cmdeploy/src/cmdeploy/cmdeploy.py @@ -5,7 +5,6 @@ along with command line option and subcommand parsing. import argparse import importlib.resources -import importlib.util import os import pathlib import shutil @@ -214,14 +213,8 @@ def test_cmd_options(parser): def test_cmd(args, out): - """Run local and online tests for chatmail deployment. + """Run local and online tests for chatmail deployment.""" - This will automatically pip-install 'deltachat' if it's not available. - """ - - x = importlib.util.find_spec("deltachat") - if x is None: - out.check_call(f"{sys.executable} -m pip install deltachat") env = os.environ.copy() if args.ssh_host: env["CHATMAIL_SSH"] = args.ssh_host diff --git a/cmdeploy/src/cmdeploy/tests/online/benchmark.py b/cmdeploy/src/cmdeploy/tests/online/benchmark.py index c65292b5..8363651f 100644 --- a/cmdeploy/src/cmdeploy/tests/online/benchmark.py +++ b/cmdeploy/src/cmdeploy/tests/online/benchmark.py @@ -42,9 +42,9 @@ class TestDC: def dc_ping_pong(): chat.send_text("ping") - msg = ac2._evtracker.wait_next_incoming_message() - msg.chat.send_text("pong") - ac1._evtracker.wait_next_incoming_message() + msg = ac2.wait_for_incoming_msg() + msg.get_snapshot().chat.send_text("pong") + ac1.wait_for_incoming_msg() benchmark(dc_ping_pong, 5) @@ -56,6 +56,6 @@ class TestDC: for i in range(10): chat.send_text(f"hello {i}") for i in range(10): - ac2._evtracker.wait_next_incoming_message() + ac2.wait_for_incoming_msg() benchmark(dc_send_10_receive_10, 5, cooldown="auto") diff --git a/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py b/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py index 50ca14b6..26691f8f 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py @@ -86,10 +86,8 @@ def test_remote(remote, imap_or_smtp): def test_use_two_chatmailservers(cmfactory, maildomain2): - ac1 = cmfactory.new_online_configuring_account(cache=False) - cmfactory.switch_maildomain(maildomain2) - ac2 = cmfactory.new_online_configuring_account(cache=False) - cmfactory.bring_accounts_online() + ac1 = cmfactory.get_online_account() + ac2 = cmfactory.get_online_account(domain=maildomain2) cmfactory.get_accepted_chat(ac1, ac2) domain1 = ac1.get_config("addr").split("@")[1] domain2 = ac2.get_config("addr").split("@")[1] diff --git a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py index b1644794..69e58777 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py @@ -62,8 +62,8 @@ class TestEndToEndDeltaChat: chat.send_text("message0") lp.sec("wait for ac2 to receive message") - msg2 = ac2._evtracker.wait_next_incoming_message() - assert msg2.text == "message0" + msg2 = ac2.wait_for_incoming_msg() + assert msg2.get_snapshot().text == "message0" def test_exceed_quota( self, cmfactory, lp, tmpdir, remote, chatmail_config, sshdomain @@ -110,26 +110,22 @@ class TestEndToEndDeltaChat: return def test_securejoin(self, cmfactory, lp, maildomain2): - ac1 = cmfactory.new_online_configuring_account(cache=False) - cmfactory.switch_maildomain(maildomain2) - ac2 = cmfactory.new_online_configuring_account(cache=False) - cmfactory.bring_accounts_online() + ac1 = cmfactory.get_online_account() + ac2 = cmfactory.get_online_account(domain=maildomain2) lp.sec("ac1: create QR code and let ac2 scan it, starting the securejoin") - qr = ac1.get_setup_contact_qr() + qr = ac1.get_qr_code() lp.sec("ac2: start QR-code based setup contact protocol") - ch = ac2.qr_setup_contact(qr) + ch = ac2.secure_join(qr) assert ch.id >= 10 - ac1._evtracker.wait_securejoin_inviter_progress(1000) + ac1.wait_for_securejoin_inviter_success() def test_dkim_header_stripped(self, cmfactory, maildomain2, lp, imap_mailbox): """Test that if a DC address receives a message, it has no DKIM-Signature and Authentication-Results headers.""" - ac1 = cmfactory.new_online_configuring_account(cache=False) - cmfactory.switch_maildomain(maildomain2) - ac2 = cmfactory.new_online_configuring_account(cache=False) - cmfactory.bring_accounts_online() + ac1 = cmfactory.get_online_account() + ac2 = cmfactory.get_online_account(domain=maildomain2) chat = cmfactory.get_accepted_chat(ac1, imap_mailbox.dc_ac) chat.send_text("message0") chat2 = cmfactory.get_accepted_chat(ac2, imap_mailbox.dc_ac) @@ -146,29 +142,28 @@ class TestEndToEndDeltaChat: assert "dkim-signature" not in msg.headers def test_read_receipts_between_instances(self, cmfactory, lp, maildomain2): - ac1 = cmfactory.new_online_configuring_account(cache=False) - cmfactory.switch_maildomain(maildomain2) - ac2 = cmfactory.new_online_configuring_account(cache=False) - cmfactory.bring_accounts_online() + ac1 = cmfactory.get_online_account() + ac2 = cmfactory.get_online_account(domain=maildomain2) lp.sec("setup encrypted comms between ac1 and ac2 on different instances") - qr = ac1.get_setup_contact_qr() - ch = ac2.qr_setup_contact(qr) + qr = ac1.get_qr_code() + ch = ac2.secure_join(qr) assert ch.id >= 10 - ac1._evtracker.wait_securejoin_inviter_progress(1000) + ac1.wait_for_securejoin_inviter_success() lp.sec("ac1 sends a message and ac2 marks it as seen") chat = ac1.create_chat(ac2) msg = chat.send_text("hi") - m = ac2._evtracker.wait_next_incoming_message() + m = ac2.wait_for_incoming_msg() m.mark_seen() # we can only indirectly wait for mark-seen to cause an smtp-error lp.sec("try to wait for markseen to complete and check error states") deadline = time.time() + 3.1 while time.time() < deadline: - msgs = m.chat.get_messages() + m_snap = m.get_snapshot() + msgs = m_snap.chat.get_messages() for msg in msgs: - assert "error" not in m.get_message_info() + assert "error" not in m.get_info() time.sleep(1) @@ -180,7 +175,7 @@ def test_hide_senders_ip_address(cmfactory, ssl_context): chat = cmfactory.get_accepted_chat(user1, user2) chat.send_text("testing submission header cleanup") - user2._evtracker.wait_next_incoming_message() + user2.wait_for_incoming_msg() addr = user2.get_config("addr") host = addr.split("@")[1] pw = user2.get_config("mail_pw") diff --git a/cmdeploy/src/cmdeploy/tests/plugin.py b/cmdeploy/src/cmdeploy/tests/plugin.py index 3f6a4333..14cea369 100644 --- a/cmdeploy/src/cmdeploy/tests/plugin.py +++ b/cmdeploy/src/cmdeploy/tests/plugin.py @@ -1,5 +1,4 @@ import imaplib -import io import itertools import os import random @@ -35,17 +34,24 @@ def pytest_runtest_setup(item): pytest.skip("skipping slow test, use --slow to run") -@pytest.fixture(scope="session") -def chatmail_config(pytestconfig): - current = basedir = Path().resolve() +def _get_chatmail_config(): + current = Path().resolve() while 1: path = current.joinpath("chatmail.ini").resolve() if path.exists(): - return read_config(path) + return read_config(path), path if current == current.parent: break current = current.parent + return None, None + +@pytest.fixture(scope="session") +def chatmail_config(pytestconfig): + config, path = _get_chatmail_config() + if config: + return config + basedir = Path().resolve() pytest.skip(f"no chatmail.ini file found in {basedir} or parent dirs") @@ -73,10 +79,17 @@ def sshdomain2(maildomain2): def pytest_report_header(): - domain = os.environ.get("CHATMAIL_DOMAIN") - if domain: - text = f"chatmail test instance: {domain}" - return ["-" * len(text), text, "-" * len(text)] + config, path = _get_chatmail_config() + domain2 = os.environ.get("CHATMAIL_DOMAIN2", "NOT SET") + domain = config.mail_domain if config else "NOT SET" + path = path if path else "NOT SET" + + lines = [ + f"chatmail.ini {domain} location: {path}", + f"chatmail2: {domain2}", + ] + sep = "-" * max(map(len, lines)) + return [sep, *lines, sep] @pytest.fixture @@ -283,79 +296,95 @@ def gencreds(chatmail_config): # -# Delta Chat testplugin re-use +# Delta Chat RPC-based test support # use the cmfactory fixture to get chatmail instance accounts # +from deltachat_rpc_client import DeltaChat, Rpc -class ChatmailTestProcess: - """Provider for chatmail instance accounts as used by deltachat.testplugin.acfactory""" - def __init__(self, pytestconfig, maildomain, gencreds, chatmail_config): - self.pytestconfig = pytestconfig - self.maildomain = maildomain - assert "." in self.maildomain, maildomain +class ChatmailACFactory: + """RPC-based account factory for chatmail testing.""" + + def __init__(self, rpc, maildomain, gencreds, chatmail_config): + self.dc = DeltaChat(rpc) + self.rpc = rpc + self._maildomain = maildomain self.gencreds = gencreds self.chatmail_config = chatmail_config - self._addr2files = {} - def get_liveconfig_producer(self): - while 1: - user, password = self.gencreds(self.maildomain) - config = { - "addr": user, - "mail_pw": password, - } - # speed up account configuration - config["mail_server"] = self.maildomain - config["send_server"] = self.maildomain - if self.chatmail_config.tls_cert_mode == "self": - # Accept self-signed TLS certificates - config["imap_certificate_checks"] = "3" - yield config + def _make_transport(self, domain): + """Build a transport config dict for the given domain.""" + addr, password = self.gencreds(domain) + transport = { + "addr": addr, + "password": password, + # Setting server explicitly skips requesting autoconfig XML, + # see https://datatracker.ietf.org/doc/draft-ietf-mailmaint-autoconfig/ + "imapServer": domain, + "smtpServer": domain, + } + if self.chatmail_config.tls_cert_mode == "self": + transport["certificateChecks"] = "acceptInvalidCertificates" + return transport - def cache_maybe_retrieve_configured_db_files(self, cache_addr, db_target_path): - pass + def get_online_account(self, domain=None): + """Create, configure and bring online a single account.""" + return self.get_online_accounts(1, domain)[0] - def cache_maybe_store_configured_db_files(self, acc): - pass + def get_online_accounts(self, num, domain=None): + """Create multiple online accounts in parallel.""" + domain = domain or self._maildomain + futures = [] + accounts = [] + for _ in range(num): + account = self.dc.add_account() + future = account.add_or_update_transport.future( + self._make_transport(domain) + ) + futures.append(future) + + # ensure messages stay in INBOX so that they can be + # concurrently fetched via extra IMAP connections during tests + account.set_config("delete_server_after", "10") + accounts.append(account) + + for future in futures: + future() + + for account in accounts: + account.bring_online() + return accounts + + def get_accepted_chat(self, ac1, ac2): + """Create a 1:1 chat between ac1 and ac2 accepted on both sides.""" + ac2.create_chat(ac1) + return ac1.create_chat(ac2) + + +@pytest.fixture(scope="session") +def rpc(tmp_path_factory): + """Start a deltachat-rpc-server process for the test session.""" + + # NB: accounts_dir must NOT already exist as directory -- + # core-rust only creates accounts.toml if the dir doesn't exist yet. + accounts_dir = str(tmp_path_factory.mktemp("dc") / "accounts") + rpc = Rpc(accounts_dir=accounts_dir) + rpc.start() + yield rpc + rpc.close() @pytest.fixture -def cmfactory(request, gencreds, tmpdir, maildomain, chatmail_config): - # cloned from deltachat.testplugin.amfactory - pytest.importorskip("deltachat") - from deltachat.testplugin import ACFactory - - testproc = ChatmailTestProcess( - request.config, maildomain, gencreds, chatmail_config +def cmfactory(rpc, gencreds, maildomain, chatmail_config): + """Return a ChatmailACFactory for creating online Delta Chat accounts.""" + return ChatmailACFactory( + rpc=rpc, + maildomain=maildomain, + gencreds=gencreds, + chatmail_config=chatmail_config, ) - class Data: - def read_path(self, path): - return - - am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=Data()) - - # Skip upstream's init_imap to prevent extra imap connections not - # needed for relay testing - am._acsetup.init_imap = lambda acc: None - - # nb. a bit hacky - # would probably be better if deltachat's test machinery grows native support - def switch_maildomain(maildomain2): - am.testprocess.maildomain = maildomain2 - - am.switch_maildomain = switch_maildomain - - yield am - if hasattr(request.node, "rep_call") and request.node.rep_call.failed: - if testproc.pytestconfig.getoption("--extra-info"): - logfile = io.StringIO() - am.dump_imap_summary(logfile=logfile) - print(logfile.getvalue()) - # request.node.add_report_section("call", "imap-server-state", s) - @pytest.fixture def remote(sshdomain):