diff --git a/.github/workflows/test-devel.yml b/.github/workflows/test-devel.yml index 41203ef8..ae88351f 100644 --- a/.github/workflows/test-devel.yml +++ b/.github/workflows/test-devel.yml @@ -990,6 +990,7 @@ jobs: cache-on-failure: true workspaces: sable -> target - run: rustc --version + - run: sudo systemctl start postgresql.service - name: Build Sable run: | cd $GITHUB_WORKSPACE/sable/ @@ -1003,7 +1004,8 @@ jobs: - env: IRCTEST_DEBUG_LOGS: ${{ runner.debug }} name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml --timeout 300' PATH=$HOME/.local/bin:$PATH PATH=$GITHUB_WORKSPACE/sable/target/debug/sbin:$GITHUB_WORKSPACE/sable/target/debug/bin:$GITHUB_WORKSPACE/sable/target/debug:$PATH + run: PYTEST_ARGS='--junit-xml pytest.xml --timeout 300' PATH=$HOME/.local/bin:$PATH + IRCTEST_POSTGRESQL_URL=postgresql://localhost IRCTEST_DEBUG_LOGS=1 PATH=$GITHUB_WORKSPACE/sable/target/debug/sbin:$GITHUB_WORKSPACE/sable/target/debug/bin:$GITHUB_WORKSPACE/sable/target/debug:$PATH make sable timeout-minutes: 30 - if: always() diff --git a/.github/workflows/test-stable.yml b/.github/workflows/test-stable.yml index 76ef5a6c..d1732ad1 100644 --- a/.github/workflows/test-stable.yml +++ b/.github/workflows/test-stable.yml @@ -1140,7 +1140,7 @@ jobs: uses: actions/checkout@v4 with: path: sable - ref: 52397dc9e0f27c3ed197f984c00f06639870716d + ref: 034c4d5dd937774099773238d8d5b8054b015607 repository: Libera-Chat/sable - name: Install rust toolchain uses: actions-rs/toolchain@v1 @@ -1154,6 +1154,7 @@ jobs: cache-on-failure: true workspaces: sable -> target - run: rustc --version + - run: sudo systemctl start postgresql.service - name: Build Sable run: | cd $GITHUB_WORKSPACE/sable/ @@ -1167,7 +1168,8 @@ jobs: - env: IRCTEST_DEBUG_LOGS: ${{ runner.debug }} name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml --timeout 300' PATH=$HOME/.local/bin:$PATH PATH=$GITHUB_WORKSPACE/sable/target/debug/sbin:$GITHUB_WORKSPACE/sable/target/debug/bin:$GITHUB_WORKSPACE/sable/target/debug:$PATH + run: PYTEST_ARGS='--junit-xml pytest.xml --timeout 300' PATH=$HOME/.local/bin:$PATH + IRCTEST_POSTGRESQL_URL=postgresql://localhost IRCTEST_DEBUG_LOGS=1 PATH=$GITHUB_WORKSPACE/sable/target/debug/sbin:$GITHUB_WORKSPACE/sable/target/debug/bin:$GITHUB_WORKSPACE/sable/target/debug:$PATH make sable timeout-minutes: 30 - if: always() diff --git a/Makefile b/Makefile index b85ce666..b270e809 100644 --- a/Makefile +++ b/Makefile @@ -4,109 +4,161 @@ PYTEST ?= python3 -m pytest # pytest-xdist is installed) PYTEST_ARGS ?= +# Will be appended at the end of the -m argument to pytest +EXTRA_MARKERS ?= + # Will be appended at the end of the -k argument to pytest EXTRA_SELECTORS ?= -BAHAMUT_SELECTORS := \ - not Ergo \ +BAHAMUT_MARKERS := \ + not implementation-specific \ and not deprecated \ and not strict \ and not IRCv3 \ + $(EXTRA_MARKERS) +BAHAMUT_SELECTORS := \ + (foo or not foo) \ $(EXTRA_SELECTORS) -CHARYBDIS_SELECTORS := \ - not Ergo \ +CHARYBDIS_MARKERS := \ + not implementation-specific \ and not deprecated \ and not strict \ + $(EXTRA_MARKERS) +CHARYBDIS_SELECTORS := \ + (foo or not foo) \ $(EXTRA_SELECTORS) +ERGO_MARKERS := \ + (Ergo or not implementation-specific) \ + and not deprecated \ + $(EXTRA_MARKERS) ERGO_SELECTORS := \ - not deprecated \ + (foo or not foo) \ $(EXTRA_SELECTORS) -HYBRID_SELECTORS := \ - not Ergo \ +HYBRID_MARKERS := \ + not implementation-specific \ and not deprecated \ + $(EXTRA_MARKERS) +HYBRID_SELECTORS := \ + (foo or not foo) \ $(EXTRA_SELECTORS) -INSPIRCD_SELECTORS := \ - not Ergo \ +INSPIRCD_MARKERS := \ + not implementation-specific \ and not deprecated \ and not strict \ + $(EXTRA_MARKERS) +INSPIRCD_SELECTORS := \ + (foo or not foo) \ $(EXTRA_SELECTORS) -IRCU2_SELECTORS := \ - not Ergo \ +IRCU2_MARKERS := \ + not implementation-specific \ and not deprecated \ and not strict \ + and not IRCv3 \ + $(EXTRA_MARKERS) +IRCU2_SELECTORS := \ + (foo or not foo) \ $(EXTRA_SELECTORS) -NEFARIOUS_SELECTORS := \ - not Ergo \ +NEFARIOUS_MARKERS := \ + not implementation-specific \ and not deprecated \ and not strict \ + $(EXTRA_MARKERS) +NEFARIOUS_SELECTORS := \ + (foo or not foo) \ $(EXTRA_SELECTORS) -SNIRCD_SELECTORS := \ - not Ergo \ +SNIRCD_MARKERS := \ + not implementation-specific \ and not deprecated \ and not strict \ + and not IRCv3 \ + $(EXTRA_MARKERS) +SNIRCD_SELECTORS := \ + (foo or not foo) \ $(EXTRA_SELECTORS) -IRC2_SELECTORS := \ - not Ergo \ +IRC2_MARKERS := \ + not implementation-specific \ and not deprecated \ and not strict \ + and not IRCv3 \ + $(EXTRA_MARKERS) +IRC2_SELECTORS := \ + (foo or not foo) \ $(EXTRA_SELECTORS) -MAMMON_SELECTORS := \ - not Ergo \ +MAMMON_MARKERS := \ + not implementation-specific \ and not deprecated \ and not strict \ + $(EXTRA_MARKERS) +MAMMON_SELECTORS := \ + (foo or not foo) \ $(EXTRA_SELECTORS) -NGIRCD_SELECTORS := \ - not Ergo \ +NGIRCD_MARKERS := \ + not implementation-specific \ and not deprecated \ and not strict \ + $(EXTRA_MARKERS) +NGIRCD_SELECTORS := \ + (foo or not foo) \ $(EXTRA_SELECTORS) -PLEXUS4_SELECTORS := \ - not Ergo \ +PLEXUS4_MARKERS := \ + not implementation-specific \ and not deprecated \ + $(EXTRA_MARKERS) +PLEXUS4_SELECTORS := \ + (foo or not foo) \ $(EXTRA_SELECTORS) -# Limnoria can actually pass all the test so there is none to exclude. -# `(foo or not foo)` serves as a `true` value so it doesn't break when -# $(EXTRA_SELECTORS) is non-empty +LIMNORIA_MARKERS := \ + not implementation-specific \ + $(EXTRA_MARKERS) LIMNORIA_SELECTORS := \ (foo or not foo) \ $(EXTRA_SELECTORS) # Tests marked with arbitrary_client_tags or react_tag can't pass because Sable does not support client tags yet -SABLE_SELECTORS := \ - not Ergo \ +# 'SablePostgresqlHistoryTestCase and private_chathistory' disabled because Sable does not (yet?) persist private messages to postgresql +SABLE_MARKERS := \ + (Sable or not implementation-specific) \ and not deprecated \ and not strict \ and not arbitrary_client_tags \ and not react_tag \ - and not list and not lusers and not time and not info \ + $(EXTRA_MARKERS) +SABLE_SELECTORS := \ + not list and not lusers and not time and not info \ + and not (SablePostgresqlHistoryTestCase and private_chathistory) \ $(EXTRA_SELECTORS) -SOLANUM_SELECTORS := \ - not Ergo \ +SOLANUM_MARKERS := \ + not implementation-specific \ and not deprecated \ and not strict \ + $(EXTRA_MARKERS) +SOLANUM_SELECTORS := \ + (foo or not foo) \ $(EXTRA_SELECTORS) -# Same as Limnoria +SOPEL_MARKERS := \ + not implementation-specific \ + $(EXTRA_MARKERS) SOPEL_SELECTORS := \ (foo or not foo) \ $(EXTRA_SELECTORS) -# TheLounge can actually pass all the test so there is none to exclude. -# `(foo or not foo)` serves as a `true` value so it doesn't break when -# $(EXTRA_SELECTORS) is non-empty +THELOUNGE_MARKERS := \ + not implementation-specific \ + $(EXTRA_MARKERS) THELOUNGE_SELECTORS := \ (foo or not foo) \ $(EXTRA_SELECTORS) @@ -115,13 +167,16 @@ THELOUNGE_SELECTORS := \ # Tests marked with react_tag can't pass because Unreal blocks +draft/react https://github.com/unrealircd/unrealircd/pull/149 # Tests marked with private_chathistory can't pass because Unreal does not implement CHATHISTORY for DMs -UNREALIRCD_SELECTORS := \ - not Ergo \ +UNREALIRCD_MARKERS := \ + not implementation-specific \ and not deprecated \ and not strict \ and not arbitrary_client_tags \ and not react_tag \ and not private_chathistory \ + $(EXTRA_MARKERS) +UNREALIRCD_SELECTORS := \ + (foo or not foo) \ $(EXTRA_SELECTORS) .PHONY: all flakes bahamut charybdis ergo inspircd ircu2 snircd irc2 mammon nefarious limnoria sable sopel solanum unrealircd @@ -137,107 +192,114 @@ bahamut: -m 'not services' \ -n 4 \ -vv -s \ + -m 'not services and $(BAHAMUT_MARKERS)' -k '$(BAHAMUT_SELECTORS)' bahamut-atheme: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.bahamut \ --services-controller=irctest.controllers.atheme_services \ - -m 'services' \ + -m 'services and $(BAHAMUT_MARKERS)' \ -k '$(BAHAMUT_SELECTORS)' bahamut-anope: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.bahamut \ --services-controller=irctest.controllers.anope_services \ - -m 'services' \ + -m 'services and $(BAHAMUT_MARKERS)' \ -k '$(BAHAMUT_SELECTORS)' charybdis: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.charybdis \ --services-controller=irctest.controllers.atheme_services \ + -m '$(CHARYBDIS_MARKERS)' -k '$(CHARYBDIS_SELECTORS)' ergo: $(PYTEST) $(PYTEST_ARGS) \ --controller irctest.controllers.ergo \ + -m '$(ERGO_MARKERS)' -k "$(ERGO_SELECTORS)" hybrid: $(PYTEST) $(PYTEST_ARGS) \ --controller irctest.controllers.hybrid \ --services-controller=irctest.controllers.anope_services \ + -m '$(HYBRID_MARKERS)' -k "$(HYBRID_SELECTORS)" inspircd: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.inspircd \ - -m 'not services' \ + -m 'not services and $(INSPIRCD_MARKERS)' \ -k '$(INSPIRCD_SELECTORS)' inspircd-atheme: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.inspircd \ --services-controller=irctest.controllers.atheme_services \ - -m 'services' \ + -m 'services and $(INSPIRCD_MARKERS)' \ -k '$(INSPIRCD_SELECTORS)' inspircd-anope: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.inspircd \ --services-controller=irctest.controllers.anope_services \ - -m 'services' \ + -m 'services and $(INSPIRCD_MARKERS)' \ -k '$(INSPIRCD_SELECTORS)' ircu2: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.ircu2 \ - -m 'not services and not IRCv3' \ + -m 'not services and $(IRCU2_MARKERS)' \ -n 4 \ -k '$(IRCU2_SELECTORS)' nefarious: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.nefarious \ - -m 'not services' \ + -m 'not services and $(NEFARIOUS_MARKERS)' \ -n 4 \ -k '$(NEFARIOUS_SELECTORS)' snircd: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.snircd \ - -m 'not services and not IRCv3' \ + -m 'not services and $(SNIRCD_MARKERS)' \ -n 4 \ -k '$(SNIRCD_SELECTORS)' irc2: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.irc2 \ - -m 'not services and not IRCv3' \ + -m 'not services and $(IRCU2_MARKERS)' \ -n 4 \ -k '$(IRC2_SELECTORS)' limnoria: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.limnoria \ + -m '$(LIMNORIA_MARKERS)' \ -k '$(LIMNORIA_SELECTORS)' mammon: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.mammon \ + -m '$(MAMMON_MARKERS)' \ -k '$(MAMMON_SELECTORS)' plexus4: $(PYTEST) $(PYTEST_ARGS) \ --controller irctest.controllers.plexus4 \ --services-controller=irctest.controllers.anope_services \ + -m '$(PLEXUS4_MARKERS)' \ -k "$(PLEXUS4_SELECTORS)" ngircd: $(PYTEST) $(PYTEST_ARGS) \ --controller irctest.controllers.ngircd \ - -m 'not services' \ + -m 'not services and $(NGIRCD_MARKERS)' \ -n 4 \ -k "$(NGIRCD_SELECTORS)" @@ -245,19 +307,20 @@ ngircd-anope: $(PYTEST) $(PYTEST_ARGS) \ --controller irctest.controllers.ngircd \ --services-controller=irctest.controllers.anope_services \ - -m 'services' \ + -m 'services and $(NGIRCD_MARKERS)' \ -k "$(NGIRCD_SELECTORS)" ngircd-atheme: $(PYTEST) $(PYTEST_ARGS) \ --controller irctest.controllers.ngircd \ --services-controller=irctest.controllers.atheme_services \ - -m 'services' \ + -m 'services and $(NGIRCD_MARKERS)' \ -k "$(NGIRCD_SELECTORS)" sable: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.sable \ + -m '$(SABLE_MARKERS)' \ -n 20 \ -k '$(SABLE_SELECTORS)' @@ -265,22 +328,25 @@ solanum: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.solanum \ --services-controller=irctest.controllers.atheme_services \ + -m '$(SOLANUM_MARKERS)' \ -k '$(SOLANUM_SELECTORS)' sopel: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.sopel \ + -m '$(SOPEL_MARKERS)' \ -k '$(SOPEL_SELECTORS)' thelounge: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.thelounge \ + -m '$(THELOUNGE_MARKERS)' \ -k '$(THELOUNGE_SELECTORS)' unrealircd: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.unrealircd \ - -m 'not services' \ + -m 'not services and $(UNREALIRCD_MARKERS)' \ -k '$(UNREALIRCD_SELECTORS)' unrealircd-5: unrealircd @@ -289,19 +355,19 @@ unrealircd-atheme: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.unrealircd \ --services-controller=irctest.controllers.atheme_services \ - -m 'services' \ + -m 'services and $(UNREALIRCD_MARKERS)' \ -k '$(UNREALIRCD_SELECTORS)' unrealircd-anope: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.unrealircd \ --services-controller=irctest.controllers.anope_services \ - -m 'services' \ + -m 'services and $(UNREALIRCD_MARKERS)' \ -k '$(UNREALIRCD_SELECTORS)' unrealircd-dlk: pifpaf run mysql -- $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.unrealircd \ --services-controller=irctest.controllers.dlk_services \ - -m 'services' \ + -m 'services and $(UNREALIRCD_MARKERS)' \ -k '$(UNREALIRCD_SELECTORS)' diff --git a/irctest/basecontrollers.py b/irctest/basecontrollers.py index 156d5c3f..a7036d0f 100644 --- a/irctest/basecontrollers.py +++ b/irctest/basecontrollers.py @@ -8,8 +8,10 @@ import shutil import socket import subprocess +import sys import tempfile import textwrap +import threading import time from typing import ( IO, @@ -67,6 +69,9 @@ class TestCaseControllerConfig: This should be used as little as possible, using the other attributes instead; as they are work with any controller.""" + sable_history_server: bool = False + """Whether to start Sable's long-term history server""" + class _BaseController: """Base class for software controllers. @@ -145,10 +150,48 @@ def kill(self) -> None: self._own_ports.remove((hostname, port)) def execute( - self, command: Sequence[Union[str, Path]], **kwargs: Any + self, + command: Sequence[Union[str, Path]], + proc_name: Optional[str] = None, + **kwargs: Any, ) -> subprocess.Popen: output_to = None if self.debug_mode else subprocess.DEVNULL - return subprocess.Popen(command, stderr=output_to, stdout=output_to, **kwargs) + proc_name = proc_name or str(command[0]) + kwargs.setdefault("stdout", output_to) + kwargs.setdefault("stderr", output_to) + stream_stdout = stream_stderr = None + if kwargs["stdout"] in (None, subprocess.STDOUT): + kwargs["stdout"] = subprocess.PIPE + + def stream_stdout() -> None: + assert proc.stdout is not None # for mypy + for line in proc.stdout: + prefix = f"{time.time():.3f} {proc_name} ".encode() + try: + sys.stdout.buffer.write(prefix + line) + except ValueError: + # "I/O operation on closed file" + pass + + if kwargs["stderr"] in (subprocess.STDOUT, None): + kwargs["stderr"] = subprocess.PIPE + + def stream_stderr() -> None: + assert proc.stderr is not None # for mypy + for line in proc.stderr: + prefix = f"{time.time():.3f} {proc_name} ".encode() + try: + sys.stdout.buffer.write(prefix + line) + except ValueError: + # "I/O operation on closed file" + pass + + proc = subprocess.Popen(command, **kwargs) + if stream_stdout is not None: + threading.Thread(target=stream_stdout, name="stream_stdout").start() + if stream_stderr is not None: + threading.Thread(target=stream_stderr, name="stream_stderr").start() + return proc class DirectoryBasedController(_BaseController): @@ -273,6 +316,7 @@ class BaseServerController(_BaseController): def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) self.faketime_enabled = False + self.services_controller = None def run( self, diff --git a/irctest/cases.py b/irctest/cases.py index 4d14b3a2..7ab0e690 100644 --- a/irctest/cases.py +++ b/irctest/cases.py @@ -842,16 +842,22 @@ def mark_services(cls: TClass) -> TClass: def mark_specifications( *specifications_str: str, deprecated: bool = False, strict: bool = False ) -> Callable[[TCallable], TCallable]: - specifications = frozenset( + specifications = { Specifications.from_name(s) if isinstance(s, str) else s for s in specifications_str - ) + } if None in specifications: raise ValueError("Invalid set of specifications: {}".format(specifications)) + is_implementation_specific = all( + spec.is_implementation_specific() for spec in specifications + ) + def decorator(f: TCallable) -> TCallable: for specification in specifications: f = getattr(pytest.mark, specification.value)(f) + if is_implementation_specific: + f = getattr(pytest.mark, "implementation-specific")(f) if strict: f = pytest.mark.strict(f) if deprecated: diff --git a/irctest/controllers/sable.py b/irctest/controllers/sable.py index 200ea6fa..f41c3997 100644 --- a/irctest/controllers/sable.py +++ b/irctest/controllers/sable.py @@ -4,8 +4,9 @@ import signal import subprocess import tempfile +import threading import time -from typing import Optional, Type +from typing import Any, Optional, Sequence, Type from irctest.basecontrollers import ( BaseServerController, @@ -14,6 +15,7 @@ NotImplementedByController, ) from irctest.cases import BaseServerTestCase +from irctest.client_mock import ClientMock from irctest.exceptions import NoMessageException from irctest.patma import ANYSTR @@ -85,7 +87,13 @@ def certs_dir() -> Path: certs_dir = tempfile.TemporaryDirectory() (Path(certs_dir.name) / "gen_certs.sh").write_text(GEN_CERTS) subprocess.run( - ["bash", "gen_certs.sh", "My.Little.Server", "My.Little.Services"], + [ + "bash", + "gen_certs.sh", + "My.Little.Server", + "My.Little.History", + "My.Little.Services", + ], cwd=certs_dir.name, check=True, ) @@ -95,10 +103,11 @@ def certs_dir() -> Path: NETWORK_CONFIG = """ { - "fanout": 1, + "fanout": 2, "ca_file": "%(certs_dir)s/ca_cert.pem", "peers": [ + { "name": "My.Little.History", "address": "%(history_hostname)s:%(history_port)s", "fingerprint": "%(history_cert_sha1)s" }, { "name": "My.Little.Services", "address": "%(services_hostname)s:%(services_port)s", "fingerprint": "%(services_cert_sha1)s" }, { "name": "My.Little.Server", "address": "%(server1_hostname)s:%(server1_port)s", "fingerprint": "%(server1_cert_sha1)s" } ] @@ -107,7 +116,7 @@ def certs_dir() -> Path: NETWORK_CONFIG_CONFIG = """ { - "object_expiry": 300, + "object_expiry": 60, // 1 minute "opers": [ { @@ -219,6 +228,58 @@ def certs_dir() -> Path: } """ +HISTORY_SERVER_CONFIG = """ +{ + "server_id": 50, + "server_name": "My.Little.History", + + "management": { + "address": "%(history_management_hostname)s:%(history_management_port)s", + "client_ca": "%(certs_dir)s/ca_cert.pem", + "authorised_fingerprints": [ + { "name": "user1", "fingerprint": "435bc6db9f22e84ba5d9652432154617c9509370" } + ] + }, + + "server": { + "database": "%(history_db_url)s", + "auto_run_migrations": true, + }, + + "event_log": { + "event_expiry": 300, // five minutes, for local testing + }, + + "tls_config": { + "key_file": "%(certs_dir)s/My.Little.History.key", + "cert_file": "%(certs_dir)s/My.Little.History.pem" + }, + + "node_config": { + "listen_addr": "%(history_hostname)s:%(history_port)s", + "cert_file": "%(certs_dir)s/My.Little.History.pem", + "key_file": "%(certs_dir)s/My.Little.History.key" + }, + + "log": { + "dir": "log/services/", + + "module-levels": { + "": "debug", + "sable_history": "trace", + }, + + "targets": [ + { + "target": "stdout", + "level": "trace", + "modules": [ "sable" ] + } + ] + } +} +""" + SERVICES_CONFIG = """ { "server_id": 99, @@ -297,7 +358,7 @@ def certs_dir() -> Path: { "target": "stdout", "level": "debug", - "modules": [ "sable_services" ] + "modules": [ "sable" ] } ] } @@ -312,6 +373,12 @@ class SableController(BaseServerController, DirectoryBasedController): """Sable processes commands very quickly, but responses for commands changing the state may be sent after later commands for messages which don't.""" + history_controller: Optional[BaseServicesController] = None + + def __init__(self, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + self.history_controller = None + def run( self, hostname: str, @@ -348,10 +415,11 @@ def run( (server1_hostname, server1_port) = self.get_hostname_and_port() (services_hostname, services_port) = self.get_hostname_and_port() + (history_hostname, history_port) = self.get_hostname_and_port() # Sable requires inbound connections to match the configured hostname, # so we can't configure 0.0.0.0 - server1_hostname = services_hostname = "127.0.0.1" + server1_hostname = history_hostname = services_hostname = "127.0.0.1" ( server1_management_hostname, @@ -361,6 +429,10 @@ def run( services_management_hostname, services_management_port, ) = self.get_hostname_and_port() + ( + history_management_hostname, + history_management_port, + ) = self.get_hostname_and_port() self.template_vars = dict( certs_dir=certs_dir(), @@ -381,6 +453,13 @@ def run( services_management_hostname=services_management_hostname, services_management_port=services_management_port, services_alias_users=SERVICES_ALIAS_USERS if run_services else "", + history_hostname=history_hostname, + history_port=history_port, + history_cert_sha1=(certs_dir() / "My.Little.History.pem.sha1") + .read_text() + .strip(), + history_management_hostname=history_management_hostname, + history_management_port=history_management_port, ) with self.open_file("configs/network.conf") as fd: @@ -411,17 +490,28 @@ def run( cwd=self.directory, preexec_fn=os.setsid, env={"RUST_BACKTRACE": "1", **os.environ}, + proc_name="sable_ircd ", ) self.pgroup_id = os.getpgid(self.proc.pid) if run_services: self.services_controller = SableServicesController(self.test_config, self) + self.services_controller.faketime_cmd = faketime_cmd self.services_controller.run( protocol="sable", server_hostname=services_hostname, server_port=services_port, ) + if self.test_config.sable_history_server: + self.history_controller = SableHistoryController(self.test_config, self) + self.history_controller.faketime_cmd = faketime_cmd + self.history_controller.run( + protocol="sable", + server_hostname=history_hostname, + server_port=history_port, + ) + def kill_proc(self) -> None: os.killpg(self.pgroup_id, signal.SIGKILL) super().kill_proc() @@ -465,11 +555,62 @@ def registerUser( case.sendLine(client, "QUIT") case.assertDisconnected(client) + def wait_for_services(self) -> None: + # FIXME: this isn't called when sable_history is enabled but sable_services + # isn't. This doesn't happen with the existing tests so this isn't an issue yet + if self.services_controller is not None: + t1 = threading.Thread(target=self.services_controller.wait_for_services) + t1.start() + if self.history_controller is not None: + t2 = threading.Thread(target=self.history_controller.wait_for_services) + t2.start() + t2.join() + if self.services_controller is not None: + t1.join() + class SableServicesController(BaseServicesController): server_controller: SableController software_name = "Sable Services" + faketime_cmd: Sequence[str] + + def wait_for_services(self) -> None: + """Overrides the default implementation, as it relies on + ``PRIVMSG NickServ: HELP``, which always succeeds on Sable. + + Instead, this relies on SASL PLAIN availability.""" + if self.services_up: + # Don't check again if they are already available + return + self.server_controller.wait_for_port() + + c = ClientMock(name="chkSASL", show_io=True) + c.connect(self.server_controller.hostname, self.server_controller.port) + + def wait() -> None: + while True: + c.sendLine("CAP LS 302") + for msg in c.getMessages(synchronize=False): + if msg.command == "CAP": + assert msg.params[-2] == "LS", msg + for cap in msg.params[-1].split(): + if cap.startswith("sasl="): + mechanisms = cap.split("=", 1)[1].split(",") + if "PLAIN" in mechanisms: + return + else: + if msg.params[0] == "*": + # End of CAP LS + time.sleep(self.server_controller.sync_sleep_time) + + wait() + + c.sendLine("QUIT") + c.getMessages() + c.disconnect() + self.services_up = True + def run(self, protocol: str, server_hostname: str, server_port: int) -> None: assert protocol == "sable" assert self.server_controller.directory is not None @@ -479,6 +620,7 @@ def run(self, protocol: str, server_hostname: str, server_port: int) -> None: self.proc = self.execute( [ + *self.faketime_cmd, "sable_services", "--foreground", "--server-conf", @@ -489,9 +631,97 @@ def run(self, protocol: str, server_hostname: str, server_port: int) -> None: cwd=self.server_controller.directory, preexec_fn=os.setsid, env={"RUST_BACKTRACE": "1", **os.environ}, + proc_name="sable_services", + ) + self.pgroup_id = os.getpgid(self.proc.pid) + + def kill_proc(self) -> None: + os.killpg(self.pgroup_id, signal.SIGKILL) + super().kill_proc() + + +class SableHistoryController(BaseServicesController): + server_controller: SableController + software_name = "Sable History Server" + faketime_cmd: Sequence[str] + + def run(self, protocol: str, server_hostname: str, server_port: int) -> None: + assert protocol == "sable" + assert self.server_controller.directory is not None + history_db_url = os.environ.get("PIFPAF_POSTGRESQL_URL") or os.environ.get( + "IRCTEST_POSTGRESQL_URL" + ) + assert history_db_url, ( + "Cannot find a postgresql database to use as backend for sable_history. " + "Either set the IRCTEST_POSTGRESQL_URL env var to a libpq URL, or " + "run `pip3 install pifpaf` and wrap irctest in a pifpaf call (ie. " + "pifpaf run postgresql -- pytest --controller=irctest.controllers.sable ...)" + ) + + with self.server_controller.open_file("configs/history_server.conf") as fd: + vals = dict(self.server_controller.template_vars) + vals["history_db_url"] = history_db_url + fd.write(HISTORY_SERVER_CONFIG % vals) + + self.proc = self.execute( + [ + *self.faketime_cmd, + "sable_history", + "--foreground", + "--server-conf", + self.server_controller.directory / "configs/history_server.conf", + "--network-conf", + self.server_controller.directory / "configs/network.conf", + ], + cwd=self.server_controller.directory, + preexec_fn=os.setsid, + env={"RUST_BACKTRACE": "1", **os.environ}, + proc_name="sable_history ", ) self.pgroup_id = os.getpgid(self.proc.pid) + def wait_for_services(self) -> None: + """Overrides the default implementation, as it relies on + ``PRIVMSG NickServ: HELP``, which always succeeds on Sable. + + Instead, this relies on SASL PLAIN availability.""" + if self.services_up: + # Don't check again if they are already available + return + self.server_controller.wait_for_port() + + c = ClientMock(name="chkHist", show_io=True) + c.connect(self.server_controller.hostname, self.server_controller.port) + c.sendLine("NICK chkHist") + c.sendLine("USER chk chk chk chk") + time.sleep(self.server_controller.sync_sleep_time) + got_end_of_motd = False + while not got_end_of_motd: + for msg in c.getMessages(synchronize=False): + if msg.command == "PING": + c.sendLine("PONG :" + msg.params[0]) + if msg.command in ("376", "422"): # RPL_ENDOFMOTD / ERR_NOMOTD + got_end_of_motd = True + + def wait() -> None: + timeout = time.time() + 10 + while time.time() < timeout: + c.sendLine("LINKS") + time.sleep(self.server_controller.sync_sleep_time) + for msg in c.getMessages(synchronize=False): + if msg.command == "364": # RPL_LINKS + if msg.params[2] == "My.Little.History": + return + + raise Exception("History server is not available") + + wait() + + c.sendLine("QUIT") + c.getMessages() + c.disconnect() + self.services_up = True + def kill_proc(self) -> None: os.killpg(self.pgroup_id, signal.SIGKILL) super().kill_proc() diff --git a/irctest/server_tests/chathistory.py b/irctest/server_tests/chathistory.py index 71521b60..2a7a2a1a 100644 --- a/irctest/server_tests/chathistory.py +++ b/irctest/server_tests/chathistory.py @@ -2,6 +2,7 @@ `IRCv3 draft chathistory `_ """ +import dataclasses import functools import secrets import time @@ -31,10 +32,22 @@ def newf(self, *args, **kwargs): return newf -@cases.mark_specifications("IRCv3") -@cases.mark_services -class ChathistoryTestCase(cases.BaseServerTestCase): - def validate_chathistory_batch(self, msgs, target): +class _BaseChathistoryTests(cases.BaseServerTestCase): + def _wait_before_chathistory(self): + """Hook for the Sable-specific tests that check the postgresql-based + CHATHISTORY implementation is sound. This implementation only kicks in + after the in-memory history is cleared, which happens after a 5 min timeout; + and this gives a chance to :class:``SablePostgresqlHistoryTestCase`` to + wait this timeout. + + For other tests, this does nothing. + """ + raise NotImplementedError("_BaseChathistoryTests._wait_before_chathistory") + + def validate_chathistory_batch(self, user, target): + # may need to try again for Sable, as it has a pretty high latency here + while not (msgs := self.getMessages(user)): + pass (start, *inner_msgs, end) = msgs self.assertMessageMatch( @@ -94,9 +107,13 @@ def testInvalidTargets(self): self.joinChannel(qux, real_chname) self.getMessages(qux) + self._wait_before_chathistory() + # test a nonexistent channel self.sendLine(bar, "CHATHISTORY LATEST #nonexistent_channel * 10") - msgs = self.getMessages(bar) + while not (msgs := self.getMessages(bar)): + # need to retry when Sable has the history server on + pass msgs = [msg for msg in msgs if msg.command != "MODE"] # :NickServ MODE +r self.assertMessageMatch( msgs[0], @@ -106,7 +123,9 @@ def testInvalidTargets(self): # as should a real channel to which one is not joined: self.sendLine(bar, "CHATHISTORY LATEST %s * 10" % (real_chname,)) - msgs = self.getMessages(bar) + while not (msgs := self.getMessages(bar)): + # need to retry when Sable has the history server on + pass self.assertMessageMatch( msgs[0], command="FAIL", @@ -175,6 +194,8 @@ def testMessagesToSelf(self): messages.append(echo.to_history_message()) self.assertEqual(echo.to_history_message(), delivery.to_history_message()) + self._wait_before_chathistory() + self.sendLine(bar, "CHATHISTORY LATEST %s * 10" % (bar,)) replies = [msg for msg in self.getMessages(bar) if msg.command == "PRIVMSG"] self.assertEqual([msg.to_history_message() for msg in replies], messages) @@ -225,9 +246,12 @@ def testChathistory(self, subcommand): echo_messages.extend( msg.to_history_message() for msg in self.getMessages(1) ) - time.sleep(0.002) + time.sleep(0.02) self.validate_echo_messages(NUM_MESSAGES, echo_messages) + + self._wait_before_chathistory() + self.validate_chathistory(subcommand, echo_messages, 1, chname) @skip_ngircd @@ -264,6 +288,8 @@ def testChathistoryNoEventPlayback(self): ) time.sleep(0.002) + self._wait_before_chathistory() + self.validate_echo_messages(NUM_MESSAGES, echo_messages) self.sendLine(1, "CHATHISTORY LATEST %s * 100" % chname) (batch_open, *messages, batch_close) = self.getMessages(1) @@ -308,6 +334,9 @@ def testChathistoryEventPlayback(self, subcommand): time.sleep(0.002) self.validate_echo_messages(NUM_MESSAGES * 2, echo_messages) + + self._wait_before_chathistory() + self.validate_chathistory(subcommand, echo_messages, 1, chname) @pytest.mark.parametrize("subcommand", SUBCOMMANDS) @@ -367,6 +396,9 @@ def testChathistoryDMs(self, subcommand): self.getMessages(2) self.validate_echo_messages(NUM_MESSAGES, echo_messages) + + self._wait_before_chathistory() + self.validate_chathistory(subcommand, echo_messages, 1, c2) self.validate_chathistory(subcommand, echo_messages, 2, c1) @@ -415,6 +447,8 @@ def testChathistoryDMs(self, subcommand): ] self.assertEqual(results, new_convo) + self._wait_before_chathistory() + # additional messages with c3 should not show up in the c1-c2 history: self.validate_chathistory(subcommand, echo_messages, 1, c2) self.validate_chathistory(subcommand, echo_messages, 2, c1) @@ -459,15 +493,15 @@ def validate_chathistory(self, subcommand, echo_messages, user, chname): def _validate_chathistory_LATEST(self, echo_messages, user, chname): INCLUSIVE_LIMIT = len(echo_messages) * 2 self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, INCLUSIVE_LIMIT)) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages, result) self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 5)) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[-5:], result) self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 1)) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[-1:], result) if self._supports_msgid(): @@ -476,7 +510,7 @@ def _validate_chathistory_LATEST(self, echo_messages, user, chname): "CHATHISTORY LATEST %s msgid=%s %d" % (chname, echo_messages[4].msgid, INCLUSIVE_LIMIT), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[5:], result) if self._supports_timestamp(): @@ -485,7 +519,7 @@ def _validate_chathistory_LATEST(self, echo_messages, user, chname): "CHATHISTORY LATEST %s timestamp=%s %d" % (chname, echo_messages[4].time, INCLUSIVE_LIMIT), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[5:], result) def _validate_chathistory_BEFORE(self, echo_messages, user, chname): @@ -496,7 +530,7 @@ def _validate_chathistory_BEFORE(self, echo_messages, user, chname): "CHATHISTORY BEFORE %s msgid=%s %d" % (chname, echo_messages[6].msgid, INCLUSIVE_LIMIT), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[:6], result) if self._supports_timestamp(): @@ -505,7 +539,7 @@ def _validate_chathistory_BEFORE(self, echo_messages, user, chname): "CHATHISTORY BEFORE %s timestamp=%s %d" % (chname, echo_messages[6].time, INCLUSIVE_LIMIT), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[:6], result) self.sendLine( @@ -513,7 +547,7 @@ def _validate_chathistory_BEFORE(self, echo_messages, user, chname): "CHATHISTORY BEFORE %s timestamp=%s %d" % (chname, echo_messages[6].time, 2), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[4:6], result) def _validate_chathistory_AFTER(self, echo_messages, user, chname): @@ -524,7 +558,7 @@ def _validate_chathistory_AFTER(self, echo_messages, user, chname): "CHATHISTORY AFTER %s msgid=%s %d" % (chname, echo_messages[3].msgid, INCLUSIVE_LIMIT), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[4:], result) if self._supports_timestamp(): @@ -533,7 +567,7 @@ def _validate_chathistory_AFTER(self, echo_messages, user, chname): "CHATHISTORY AFTER %s timestamp=%s %d" % (chname, echo_messages[3].time, INCLUSIVE_LIMIT), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[4:], result) self.sendLine( @@ -541,7 +575,7 @@ def _validate_chathistory_AFTER(self, echo_messages, user, chname): "CHATHISTORY AFTER %s timestamp=%s %d" % (chname, echo_messages[3].time, 3), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[4:7], result) def _validate_chathistory_BETWEEN(self, echo_messages, user, chname): @@ -558,7 +592,7 @@ def _validate_chathistory_BETWEEN(self, echo_messages, user, chname): INCLUSIVE_LIMIT, ), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[1:-1], result) self.sendLine( @@ -571,7 +605,7 @@ def _validate_chathistory_BETWEEN(self, echo_messages, user, chname): INCLUSIVE_LIMIT, ), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[1:-1], result) # BETWEEN forwards and backwards with a limit, should get @@ -581,7 +615,7 @@ def _validate_chathistory_BETWEEN(self, echo_messages, user, chname): "CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d" % (chname, echo_messages[0].msgid, echo_messages[-1].msgid, 3), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[1:4], result) self.sendLine( @@ -589,7 +623,7 @@ def _validate_chathistory_BETWEEN(self, echo_messages, user, chname): "CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d" % (chname, echo_messages[-1].msgid, echo_messages[0].msgid, 3), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[-4:-1], result) if self._supports_timestamp(): @@ -604,7 +638,7 @@ def _validate_chathistory_BETWEEN(self, echo_messages, user, chname): INCLUSIVE_LIMIT, ), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[1:-1], result) self.sendLine( user, @@ -616,21 +650,21 @@ def _validate_chathistory_BETWEEN(self, echo_messages, user, chname): INCLUSIVE_LIMIT, ), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[1:-1], result) self.sendLine( user, "CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d" % (chname, echo_messages[0].time, echo_messages[-1].time, 3), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[1:4], result) self.sendLine( user, "CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d" % (chname, echo_messages[-1].time, echo_messages[0].time, 3), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[-4:-1], result) def _validate_chathistory_AROUND(self, echo_messages, user, chname): @@ -640,7 +674,7 @@ def _validate_chathistory_AROUND(self, echo_messages, user, chname): "CHATHISTORY AROUND %s msgid=%s %d" % (chname, echo_messages[7].msgid, 1), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual([echo_messages[7]], result) self.sendLine( @@ -648,7 +682,7 @@ def _validate_chathistory_AROUND(self, echo_messages, user, chname): "CHATHISTORY AROUND %s msgid=%s %d" % (chname, echo_messages[7].msgid, 3), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertEqual(echo_messages[6:9], result) if self._supports_timestamp(): @@ -657,7 +691,7 @@ def _validate_chathistory_AROUND(self, echo_messages, user, chname): "CHATHISTORY AROUND %s timestamp=%s %d" % (chname, echo_messages[7].time, 3), ) - result = self.validate_chathistory_batch(self.getMessages(user), chname) + result = self.validate_chathistory_batch(user, chname) self.assertIn(echo_messages[7], result) @pytest.mark.arbitrary_client_tags @@ -718,6 +752,8 @@ def validate_tagmsg(msg, target, msgid): self.assertEqual(len(relay), 1) validate_tagmsg(relay[0], chname, msgid) + self._wait_before_chathistory() + self.sendLine(1, "CHATHISTORY LATEST %s * 10" % (chname,)) history_tagmsgs = [ msg for msg in self.getMessages(1) if msg.command == "TAGMSG" @@ -814,8 +850,95 @@ def validate_msg(msg): validate_msg(relay) +@cases.mark_specifications("IRCv3") +@cases.mark_services +class ChathistoryTestCase(_BaseChathistoryTests): + def _wait_before_chathistory(self): + """does nothing""" + pass + + assert {f"_validate_chathistory_{cmd}" for cmd in SUBCOMMANDS} == { meth_name for meth_name in dir(ChathistoryTestCase) if meth_name.startswith("_validate_chathistory_") }, "ChathistoryTestCase.validate_chathistory and SUBCOMMANDS are out of sync" + + +@cases.mark_specifications("Sable") +@cases.mark_services +class SablePostgresqlHistoryTestCase(_BaseChathistoryTests): + # for every wall clock second, 15 seconds pass for the server. + # at x30, links between nodes timeout. + faketime = "+1y x15" + + @staticmethod + def config() -> cases.TestCaseControllerConfig: + return dataclasses.replace( # type: ignore[no-any-return] + _BaseChathistoryTests.config(), + sable_history_server=True, + ) + + def _wait_before_chathistory(self): + """waits 6 seconds which appears to be a 1.5 min to Sable; which goes over + the 1 min timeout for in-memory history (+ 1 min because the cleanup job + only runs every min)""" + assert self.controller.faketime_enabled, "faketime is not installed" + time.sleep(8) + + +@cases.mark_specifications("Sable") +@cases.mark_services +class SableExpiringHistoryTestCase(cases.BaseServerTestCase): + faketime = "+1y x15" + + def _wait_before_chathistory(self): + """waits 6 seconds which appears to be a 1.5 min to Sable; which goes over + the 1 min timeout for in-memory history (+ 1 min because the cleanup job + only runs every min)""" + assert self.controller.faketime_enabled, "faketime is not installed" + time.sleep(8) + + def testChathistoryExpired(self): + """Checks that Sable forgets about messages if the history server is not available""" + self.connectClient( + "bar", + capabilities=[ + "message-tags", + "server-time", + "echo-message", + "batch", + "labeled-response", + "sasl", + CHATHISTORY_CAP, + ], + skip_if_cap_nak=True, + ) + chname = "#chan" + secrets.token_hex(12) + self.joinChannel(1, chname) + self.getMessages(1) + self.getMessages(1) + + self.sendLine(1, f"PRIVMSG {chname} :this is a message") + self.getMessages(1) + + self._wait_before_chathistory() + + self.sendLine(1, f"CHATHISTORY LATEST {chname} * 10") + + while not (messages := self.getMessages(1)): + # Sable processes CHATHISTORY asynchronously, which can be pretty slow as it + # sends cross-server requests. This means we can't just rely on a PING-PONG + # or the usual time.sleep(self.controller.sync_sleep_time) to make sure + # the ircd replied to us + time.sleep(self.controller.sync_sleep_time) + + (start, *middle, end) = messages + self.assertMessageMatch( + start, command="BATCH", params=[StrRe(r"\+.*"), "chathistory", chname] + ) + batch_tag = start.params[0][1:] + self.assertMessageMatch(end, command="BATCH", params=["-" + batch_tag]) + self.assertEqual( + len(middle), 0, f"Got messages that should be expired: {middle}" + ) diff --git a/irctest/specifications.py b/irctest/specifications.py index 9c4617bc..41f82b4c 100644 --- a/irctest/specifications.py +++ b/irctest/specifications.py @@ -9,6 +9,7 @@ class Specifications(enum.Enum): RFC2812 = "RFC2812" IRCv3 = "IRCv3" # Mark with capabilities whenever possible Ergo = "Ergo" + Sable = "Sable" Ircdocs = "ircdocs" """Any document on ircdocs.horse (especially defs.ircdocs.horse), @@ -24,6 +25,9 @@ def from_name(cls, name: str) -> Specifications: return spec raise ValueError(name) + def is_implementation_specific(self) -> bool: + return self in (Specifications.Ergo, Specifications.Sable) + @enum.unique class Capabilities(enum.Enum): diff --git a/pytest.ini b/pytest.ini index 375f2bb3..1c13017f 100644 --- a/pytest.ini +++ b/pytest.ini @@ -7,7 +7,12 @@ markers = IRCv3 modern ircdocs + + # implementations for which we have specific test get two markers: + # the implementation name and 'implementation-specific' + implementation-specific Ergo + Sable # misc marks strict diff --git a/workflows.yml b/workflows.yml index fbf7b951..4c64127a 100644 --- a/workflows.yml +++ b/workflows.yml @@ -249,7 +249,7 @@ software: name: Sable repository: Libera-Chat/sable refs: - stable: 52397dc9e0f27c3ed197f984c00f06639870716d + stable: 034c4d5dd937774099773238d8d5b8054b015607 release: null devel: master devel_release: null @@ -268,6 +268,9 @@ software: workspaces: "sable -> target" cache-on-failure: true - run: rustc --version + - run: start postgresql + run: "sudo systemctl start postgresql.service" + env: "IRCTEST_POSTGRESQL_URL=postgresql://localhost IRCTEST_DEBUG_LOGS=1" separate_build_job: false build_script: | cd $GITHUB_WORKSPACE/sable/