From e58266fbbca058a9620339b73dd1a7c5b1e0f803 Mon Sep 17 00:00:00 2001 From: Johannes Ernst Date: Tue, 15 Oct 2024 14:24:03 -0700 Subject: [PATCH 1/2] Remove wait_util_* methods from FediverseNode. Replace by utils' poll_until() and exposing the underlying polled functions. This simplifies things. Split prompt_user into two functions, which can have more sane / lint-friendly signature and allows us to remove a bunch of cast()'s --- Makefile | 10 +- src/feditest/nodedrivers/__init__.py | 8 - .../nodedrivers/fallback/fediverse.py | 84 +++----- src/feditest/nodedrivers/manual/__init__.py | 5 +- src/feditest/nodedrivers/mastodon/__init__.py | 202 ++++++------------ .../nodedrivers/wordpress/__init__.py | 14 +- src/feditest/protocols/activitypub/utils.py | 8 +- src/feditest/protocols/fediverse/__init__.py | 50 +---- src/feditest/utils.py | 66 ++++-- tests.smoke/tests/node_with_mastodon_api.py | 3 +- .../nodes_with_mastodon_api_communicate.py | 7 +- 11 files changed, 171 insertions(+), 286 deletions(-) diff --git a/Makefile b/Makefile index 543f301..6dbe370 100644 --- a/Makefile +++ b/Makefile @@ -51,17 +51,17 @@ tests : tests.unit tests.smoke tests.unit : $(VENV)/bin/pytest -v -tests.smoke : tests.smoke.webfinger tests.smoke.fediverse - -# Run WordPress tests first: they run faster as installation is faster +tests.smoke : tests.smoke.webfinger tests.smoke.api tests.smoke.fediverse tests.smoke.webfinger : $(FEDITEST) run --testsdir tests.smoke/tests --session tests.smoke/webfinger.session.json --node client=tests.smoke/imp.node.json --node server=tests.smoke/wordpress.ubos.node.json $(DOMAIN) $(FEDITEST) run --testsdir tests.smoke/tests --session tests.smoke/webfinger.session.json --node client=tests.smoke/imp.node.json --node server=tests.smoke/mastodon.ubos.node.json $(DOMAIN) -tests.smoke.fediverse : +tests.smoke.api : $(FEDITEST) run --testsdir tests.smoke/tests --session tests.smoke/mastodon_api.session.json --node server=tests.smoke/wordpress.ubos.node.json $(DOMAIN) $(FEDITEST) run --testsdir tests.smoke/tests --session tests.smoke/mastodon_api.session.json --node server=tests.smoke/mastodon.ubos.node.json $(DOMAIN) + +tests.smoke.fediverse : $(FEDITEST) run --testsdir tests.smoke/tests --session tests.smoke/mastodon_api_mastodon_api.session.json --node leader_node=tests.smoke/mastodon.ubos.node.json --node follower_node=tests.smoke/mastodon.ubos.node.json $(DOMAIN) $(FEDITEST) run --testsdir tests.smoke/tests --session tests.smoke/mastodon_api_mastodon_api.session.json --node leader_node=tests.smoke/wordpress.ubos.node.json --node follower_node=tests.smoke/mastodon.ubos.node.json $(DOMAIN) @@ -77,4 +77,4 @@ release : @echo The actual push to pypi.org you need to do manually. Enter: @echo venv.release/bin/twine upload dist/* -.PHONY: all default venv build lint tests tests.unit tests.smoke tests.smoke.webfinger tests.smoke.fediverse release +.PHONY: all default venv build lint tests tests.unit tests.smoke tests.smoke.webfinger tests.smoke.api tests.smoke.fediverse release diff --git a/src/feditest/nodedrivers/__init__.py b/src/feditest/nodedrivers/__init__.py index 69eb7aa..601030e 100644 --- a/src/feditest/nodedrivers/__init__.py +++ b/src/feditest/nodedrivers/__init__.py @@ -627,11 +627,3 @@ class NodeSpecificationInvalidError(RuntimeError): """ def __init__(self, node_driver: NodeDriver, parameter: str, details: str ): super().__init__(f"Node specification is invalid for {node_driver}, parameter {parameter}: {details}" ) - - -class TimeoutException(RuntimeError): - """ - A result has not arrived within the expected time period. - """ - def __init__(self, msg: str, timeout: float): - super().__init__(f'{ msg } (timeout: { timeout })') diff --git a/src/feditest/nodedrivers/fallback/fediverse.py b/src/feditest/nodedrivers/fallback/fediverse.py index 0d38b3e..6753ba9 100644 --- a/src/feditest/nodedrivers/fallback/fediverse.py +++ b/src/feditest/nodedrivers/fallback/fediverse.py @@ -12,7 +12,6 @@ NodeDriver, NonExistingAccount, OutOfAccountsException, - TimeoutException, APP_PAR, APP_VERSION_PAR, HOSTNAME_PAR @@ -28,24 +27,24 @@ FediverseNonExistingAccount ) from feditest.testplan import TestPlanConstellationNode, TestPlanNodeAccountField, TestPlanNodeNonExistingAccountField -from feditest.utils import appname_validate, boolean_parse_validate, hostname_validate, https_uri_validate, prompt_user +from feditest.utils import appname_validate, boolean_parse_validate, hostname_validate, https_uri_validate, prompt_user, prompt_user_parse_validate class FallbackFediverseNode(FediverseNode): # Python 3.12 @override def provision_account_for_role(self, role: str | None = None) -> Account | None: - userid = cast(str, prompt_user( + userid = prompt_user_parse_validate( f'Node { self }:' + f' for the account with account role "{ role }", enter its userid (the user part of the acct: URI) (node account field "{ USERID_ACCOUNT_FIELD.name }"): ', - parse_validate=userid_validate)) + parse_validate=userid_validate) return FediverseAccount(role, userid) def provision_non_existing_account_for_role(self, role: str | None = None) -> NonExistingAccount | None: - userid = cast(str, prompt_user( + userid = prompt_user_parse_validate( f'Node { self }:' + f' provide the userid of a non-existing account with account role "{ role }" (the user part of the with acct: URI) (node non_existing_account field "{ USERID_NON_EXISTING_ACCOUNT_FIELD.name }"): ', - parse_validate=userid_validate)) + parse_validate=userid_validate) return FediverseNonExistingAccount(role, userid) @@ -68,32 +67,35 @@ def obtain_non_existing_actor_acct_uri(self, rolename: str | None = None ) -> st # Python 3.12 @override def make_create_note(self, actor_acct_uri: str, content: str, deliver_to: list[str] | None = None) -> str: if deliver_to : - return cast(str, prompt_user( + return prompt_user_parse_validate( f'On FediverseNode "{ self.hostname }", make actor "{ actor_acct_uri }" create a Note' + ' to be delivered to ' + ", ".join(deliver_to) + ' and enter its URI when created.' - + f' Note content:"""\n{ content }\n"""' )) - return cast(str, prompt_user( + + f' Note content:"""\n{ content }\n"""', + parse_validate=https_uri_validate) + return prompt_user_parse_validate( f'On FediverseNode "{ self.hostname }", make actor "{ actor_acct_uri }" create a Note' + ' and enter its URI when created.' - + f' Note content:"""\n{ content }\n"""' )) + + f' Note content:"""\n{ content }\n"""', + parse_validate=https_uri_validate) # Python 3.12 @override def make_announce_object(self, actor_acct_uri, to_be_announced_object_uri: str) -> str: - return cast(str, prompt_user( + return prompt_user_parse_validate( f'On FediverseNode "{ self.hostname }", make actor "{ actor_acct_uri }" boost "{ to_be_announced_object_uri }"' + ' and enter the Announce object\'s local URI:', - parse_validate=https_uri_validate)) + parse_validate=https_uri_validate) # Python 3.12 @override def make_reply_note(self, actor_acct_uri, to_be_replied_to_object_uri: str, reply_content: str) -> str: - return cast(str, prompt_user( + return prompt_user_parse_validate( f'On FediverseNode "{ self.hostname }", make actor "{ actor_acct_uri }" reply to object with "{ to_be_replied_to_object_uri }"' + ' and enter the Announce object\'s URI when created.' - + f' Reply content:"""\n{ reply_content }\n"""' )) + + f' Reply content:"""\n{ reply_content }\n"""', + parse_validate=https_uri_validate) # Python 3.12 @override @@ -102,48 +104,26 @@ def make_follow(self, actor_acct_uri: str, to_follow_actor_acct_uri: str) -> Non f'On FediverseNode "{ self.hostname }", make actor "{ actor_acct_uri }" follow actor "{ to_follow_actor_acct_uri }"' + ' and hit return when done.') - # We leave the NotImplementedByNodeError raised by the superclass for all other follow-related actions # until we have a better idea :-) # Python 3.12 @override - def wait_until_actor_is_following_actor(self, actor_acct_uri: str, to_be_followed_actor_acct_uri: str, max_wait: float = 5.) -> None: + def actor_has_received_note(self, actor_acct_uri: str, object_uri: str) -> str | None: answer = prompt_user( - f'On FediverseNode "{ self.hostname }", wait until actor "{ actor_acct_uri }" is following actor "{ to_be_followed_actor_acct_uri }"' - + ' and enter "true"; "false" if it didn\'t happen.', - parse_validate=boolean_parse_validate) - if not answer: - raise TimeoutException(f'Actor { actor_acct_uri } not following actor { to_be_followed_actor_acct_uri}.', max_wait) - - - # Python 3.12 @override - def wait_until_actor_is_followed_by_actor(self, actor_acct_uri: str, to_be_following_actor_acct_uri: str, max_wait: float = 5.) -> None: - answer = prompt_user( - f'On FediverseNode "{ self.hostname }", wait until actor "{ actor_acct_uri }" is followed by actor "{ to_be_following_actor_acct_uri }"' - + ' and enter "true"; "false" if it didn\'t happen.', - parse_validate=boolean_parse_validate) - if not answer: - raise TimeoutException(f'Actor { actor_acct_uri } not followed by actor { to_be_following_actor_acct_uri}.', max_wait) + f'On FediverseNode "{ self.hostname }", has actor "{ actor_acct_uri }" received the note "{ object_uri }"?' + + ' Enter the content of the note, or leave empty if it didn\'t happen.') + if answer: + return answer + return None # Python 3.12 @override - def wait_until_actor_is_unfollowing_actor(self, actor_acct_uri: str, to_be_unfollowed_actor_acct_uri: str, max_wait: float = 5.) -> None: - answer = prompt_user( - f'On FediverseNode "{ self.hostname }", wait until actor "{ actor_acct_uri }" is not following any more actor "{ to_be_unfollowed_actor_acct_uri }"' - + ' and enter "true"; "false" if it didn\'t happen.', - parse_validate=boolean_parse_validate) - if not answer: - raise TimeoutException(f'Actor { actor_acct_uri } still following actor { to_be_unfollowed_actor_acct_uri}.', max_wait) - - - # Python 3.12 @override - def wait_until_actor_is_unfollowed_by_actor(self, actor_acct_uri: str, to_be_unfollowing_actor_acct_uri: str, max_wait: float = 5.) -> None: - answer = prompt_user( - f'On FediverseNode "{ self.hostname }", wait until in actor "{ actor_acct_uri }" is not followed any more by actor "{ to_be_unfollowing_actor_acct_uri }"' - + ' and enter "true"; "false" if it didn\'t happen.', + def actor_is_following_actor(self, actor_acct_uri: str, leader_actor_acct_uri: str) -> bool: + answer = prompt_user_parse_validate( + f'On FediverseNode "{ self.hostname }", is actor "{ actor_acct_uri }" following actor "{ leader_actor_acct_uri }"?' + + ' Enter "true" or "false".', parse_validate=boolean_parse_validate) - if not answer: - raise TimeoutException(f'Actor { actor_acct_uri } is still followed by actor { to_be_unfollowing_actor_acct_uri}.', max_wait) + return answer # From WebFingerServer @@ -189,11 +169,13 @@ def create_configuration_account_manager(self, rolename: str, test_plan_node: Te hostname = test_plan_node.parameter(HOSTNAME_PAR) if not hostname: - hostname = prompt_user(f'Enter the hostname for the Node of constellation role "{ rolename }" (node parameter "hostname"): ', - parse_validate=hostname_validate) + hostname = prompt_user_parse_validate( + f'Enter the hostname for the Node of constellation role "{ rolename }" (node parameter "hostname"): ', + parse_validate=hostname_validate) if not app: - app = prompt_user(f'Enter the name of the app at constellation role "{ rolename }" and hostname "{ hostname }" (node parameter "app"): ', - parse_validate=appname_validate) + app = prompt_user_parse_validate( + f'Enter the name of the app at constellation role "{ rolename }" and hostname "{ hostname }" (node parameter "app"): ', + parse_validate=appname_validate) accounts : list[Account] = [] if test_plan_node.accounts: diff --git a/src/feditest/nodedrivers/manual/__init__.py b/src/feditest/nodedrivers/manual/__init__.py index aa40ddc..25557a0 100644 --- a/src/feditest/nodedrivers/manual/__init__.py +++ b/src/feditest/nodedrivers/manual/__init__.py @@ -14,8 +14,9 @@ class FediverseManualNodeDriver(AbstractFallbackFediverseNodeDriver): """ # Python 3.12 @override def _provision_node(self, rolename: str, config: NodeConfiguration, account_manager: AccountManager | None) -> FediverseNode: - prompt_user(f'Manually provision the Node for constellation role { rolename }' - + f' at host { config.hostname } with app { config.app } and hit return when done.') + prompt_user( + f'Manually provision the Node for constellation role { rolename }' + + f' at host { config.hostname } with app { config.app } and hit return when done.') return FallbackFediverseNode(rolename, config, account_manager) diff --git a/src/feditest/nodedrivers/mastodon/__init__.py b/src/feditest/nodedrivers/mastodon/__init__.py index e666701..55ecb16 100644 --- a/src/feditest/nodedrivers/mastodon/__init__.py +++ b/src/feditest/nodedrivers/mastodon/__init__.py @@ -7,8 +7,7 @@ import importlib import requests import sys -import time -from typing import Any, Callable, cast +from typing import cast from feditest.nodedrivers import ( Account, @@ -18,7 +17,6 @@ NodeDriver, NonExistingAccount, NotImplementedByNodeError, - TimeoutException, APP_PAR, APP_VERSION_PAR, HOSTNAME_PAR @@ -34,9 +32,22 @@ userid_validate ) from feditest.reporting import is_trace_active, trace -from feditest.testplan import InvalidAccountSpecificationException, TestPlanConstellationNode, TestPlanNodeAccountField, TestPlanNodeNonExistingAccountField, TestPlanNodeParameter -from feditest.utils import boolean_parse_validate, email_validate, find_first_in_array, hostname_validate, prompt_user, ParsedUri, ParsedAcctUri - +from feditest.testplan import ( + InvalidAccountSpecificationException, + TestPlanConstellationNode, + TestPlanNodeAccountField, + TestPlanNodeNonExistingAccountField, + TestPlanNodeParameter +) +from feditest.utils import ( + boolean_parse_validate, + email_validate, + find_first_in_array, + hostname_validate, + prompt_user_parse_validate, + ParsedUri, + ParsedAcctUri +) # We use the Mastodon.py module primarily because of its built-in support for rate limiting. # Also it seems to have implemented some workarounds for inconsistent implementations by @@ -359,104 +370,52 @@ def make_follow_undo(self, actor_acct_uri: str, following_actor_acct_uri: str) - raise ValueError(f'Account not found with Actor URI: { following_actor_acct_uri }') - # Python 3.12 @override - def wait_until_actor_has_received_note(self, actor_acct_uri: str, object_uri: str, max_wait: float = 5.) -> str: - trace('wait_until_actor_has_received_note:') + # Python 3.12 @override + def actor_has_received_note(self, actor_acct_uri: str, object_uri: str) -> str | None: + trace('actor_has_received_note:') mastodon_client = self._get_mastodon_client_by_actor_acct_uri(actor_acct_uri) - - def find_note(): - """ - Depending on how the Note is addressed and follow status, Mastodon puts it into the Home timeline or only - into notifications. - """ - elements = mastodon_client.timeline_home(local=True, remote=True) - ret = find_first_in_array( elements, lambda s: s.uri == object_uri) - if not ret: - elements = mastodon_client.notifications() - parent_ret = find_first_in_array( elements, lambda s: s.status.uri == object_uri) - ret = parent_ret.status if parent_ret else None - return ret - - response = self._poll_until_result( # may throw - find_note, - int(max_wait), - 1.0, - f'Expected object { object_uri } has not arrived in inbox of actor { actor_acct_uri }' - ) - trace(f'wait_for_object_in_inbox returns with { response }') - return response.content - - - # Python 3.12 @override - def wait_until_actor_is_following_actor(self, actor_acct_uri: str, to_be_followed_uri: str, max_wait: float = 5.) -> None: - trace(f'wait_until_actor_is_following_actor: actor_acct_uri = { actor_acct_uri }, to_be_followed_uri = { to_be_followed_uri }') + # Depending on how the Note is addressed and follow status, Mastodon puts it into the Home timeline or only + # into notifications. + elements = mastodon_client.timeline_home(local=True, remote=True) + response = find_first_in_array(elements, lambda s: s.uri == object_uri) + if not response: + elements = mastodon_client.notifications() + notifications_response = find_first_in_array(elements, lambda s: s.status.uri == object_uri) + if notifications_response: + response = notifications_response.status + if response: + return response.content + return None + + + # Python 3.12 @override + def actor_is_following_actor(self, actor_acct_uri: str, leader_actor_acct_uri: str) -> bool: + trace(f'actor_is_following_actor: actor_acct_uri = { actor_acct_uri }, leader_actor_acct_uri = { leader_actor_acct_uri }') account = self._get_account_by_actor_acct_uri(actor_acct_uri) if account is None: raise ValueError(f'Cannot find Account on { self }: "{ actor_acct_uri }"') mastodon_client = account.mastodon_user_client - if to_be_followed_account := self._find_account_dict_by_other_actor_acct_uri(mastodon_client, to_be_followed_uri): - self._poll_until_result( # may throw - lambda: self._is_following(account, to_be_followed_account), - int(max_wait), - 1.0, - f'Actor { actor_acct_uri } is not following { to_be_followed_uri }') - return - raise ValueError(f'Cannot find account on { self }: "{ to_be_followed_uri }"') - - - # Python 3.12 @override - def wait_until_actor_is_followed_by_actor(self, actor_acct_uri: str, to_be_following_uri: str, max_wait: float = 5.) -> None: - trace(f'wait_until_actor_is_followed_by_actor: actor_acct_uri = { actor_acct_uri }, to_be_followed_uri = { to_be_following_uri }') - account = self._get_account_by_actor_acct_uri(actor_acct_uri) - if account is None: - raise ValueError(f'Cannot find Account on { self }: "{ actor_acct_uri }"') - mastodon_client = account.mastodon_user_client - - if to_be_following_account := self._find_account_dict_by_other_actor_acct_uri(mastodon_client, to_be_following_uri): - self._poll_until_result( # may throw - lambda: self._is_followed_by(account, to_be_following_account), - int(max_wait), - 1.0, - f'Actor { actor_acct_uri } is not followed by { to_be_following_uri }') - return - raise ValueError(f'Cannot find account on { self }: "{ to_be_following_uri }"') - - - # Python 3.12 @override - def wait_until_actor_is_unfollowing_actor(self, actor_acct_uri: str, to_be_unfollowed_uri: str, max_wait: float = 5.) -> None: - trace(f'wait_until_actor_is_unfollowing_actor: actor_acct_uri = { actor_acct_uri }, to_be_unfollowed_uri = { to_be_unfollowed_uri }') - account = self._get_account_by_actor_acct_uri(actor_acct_uri) - if account is None: - raise ValueError(f'Cannot find Account on { self }: "{ actor_acct_uri }"') - mastodon_client = account.mastodon_user_client + relationships = mastodon_client.account_following(account.internal_userid) + if relationships: + relationship = find_first_in_array(relationships, lambda r: r.acct == leader_actor_acct_uri[5:]) # remove acct: + return relationship is not None - if to_be_unfollowed_account := self._find_account_dict_by_other_actor_acct_uri(mastodon_client, to_be_unfollowed_uri): - self._poll_until_result( # may throw - lambda: not self._is_following(account, to_be_unfollowed_account), - int(max_wait), - 1.0, - f'Actor { actor_acct_uri } is still following { to_be_unfollowed_uri }') - return - raise ValueError(f'Account not found with Actor URI: { to_be_unfollowed_uri }') + return False - # Python 3.12 @override - def wait_until_actor_is_unfollowed_by_actor(self, actor_acct_uri: str, to_be_unfollowing_uri: str, max_wait: float = 5.) -> None: - trace(f'wait_until_actor_is_unfollowed_by_actor: actor_acct_uri = { actor_acct_uri }, to_be_unfollowing_uri = { to_be_unfollowing_uri }') + def actor_is_followed_by_actor(self, actor_acct_uri: str, follower_actor_acct_uri: str) -> bool: + trace(f'actor_is_followed_by_actor: actor_acct_uri = { actor_acct_uri }, follower_actor_acct_uri = { follower_actor_acct_uri }') account = self._get_account_by_actor_acct_uri(actor_acct_uri) if account is None: raise ValueError(f'Cannot find Account on { self }: "{ actor_acct_uri }"') mastodon_client = account.mastodon_user_client - if to_be_unfollowing_account := self._find_account_dict_by_other_actor_acct_uri(mastodon_client, to_be_unfollowing_uri): - self._poll_until_result( # may throw - lambda: not self._is_followed_by(account, to_be_unfollowing_account), - int(max_wait), - 1.0, - f'Actor { actor_acct_uri } is still followed by { to_be_unfollowing_uri }') - return - raise ValueError(f'Account not found with Actor URI: { to_be_unfollowing_uri }') + relationships = mastodon_client.account_followers(account.internal_userid) # this returns a list + if relationships: + relationship = find_first_in_array(relationships, lambda r: r.acct == follower_actor_acct_uri[5:]) # remove acct: + return relationship is not None + return False # From ActivityPubNode @@ -535,28 +494,28 @@ def obtain_non_existing_account_identifier(self, rolename: str | None = None ) - # Python 3.12 @override def provision_account_for_role(self, role: str | None = None) -> Account | None: context_msg = f'Mastodon Node { self }: ' - userid = cast(str, prompt_user( + userid = prompt_user_parse_validate( context_msg + f' provide the userid of an existing account for account role "{ role }" (node account field "{ USERID_ACCOUNT_FIELD.name }"): ', - parse_validate=userid_validate)) - password = cast(str, prompt_user( + parse_validate=userid_validate) + password = prompt_user_parse_validate( context_msg + f' provide the password for account "{ userid }", account role "{ role }" (node account field "{ PASSWORD_ACCOUNT_FIELD.name }"): ', - parse_validate=_password_validate)) - email = cast(str, prompt_user( + parse_validate=_password_validate) + email = prompt_user_parse_validate( context_msg + f' provide the email for account "{ userid }", account role "{ role }" (node account field "{ EMAIL_ACCOUNT_FIELD.name }"): ', - parse_validate=_password_validate)) + parse_validate=_password_validate) return MastodonUserPasswordAccount(role, userid, password, email) def provision_non_existing_account_for_role(self, role: str | None = None) -> NonExistingAccount | None: context_msg = f'Mastodon Node { self }: ' - userid = cast(str, prompt_user( + userid = prompt_user_parse_validate( context_msg + f' provide the userid of a non-existing account for account role "{ role }" (node non_existing_account field "{ USERID_NON_EXISTING_ACCOUNT_FIELD.name }"): ', - parse_validate=userid_validate)) + parse_validate=userid_validate) return FediverseNonExistingAccount(role, userid) @@ -654,48 +613,6 @@ def _find_note_dict_by_uri(self, mastodon_client: Mastodon, uri: str) -> AttribA return ret - def _is_following(self, account: MastodonAccount, candidate_leader: AttribAccessDict) -> bool: - """ - Determine whether the Actor of the specified Mastodon client is following the candidate_leader. - """ - mastodon_client = account.mastodon_user_client - - relationships = mastodon_client.account_following(account.internal_userid) # this returns a list - if relationships: - relationship = find_first_in_array(relationships, lambda r: r.acct == candidate_leader.acct) - return relationship is not None - return False - - - def _is_followed_by(self, account: MastodonAccount, candidate_follower: AttribAccessDict) -> bool: - """ - Determine whether the Actor of the specified Mastodon client has the candidate_follower as follower. - """ - mastodon_client = account.mastodon_user_client - - relationships = mastodon_client.account_followers(account.internal_userid) # this returns a list - if relationships: - relationship = find_first_in_array(relationships, lambda r: r.acct == candidate_follower.acct) - return relationship is not None - return False - - - def _poll_until_result(self, - condition: Callable[[], Any | None], - retry_count: int, - retry_interval: float, - msg: str | None = None - ) -> Any: - for _ in range(retry_count): - response = condition() - if response: - return response - time.sleep(retry_interval) - if not msg: - msg = 'Expected object has not arrived in time' - raise TimeoutException(msg, retry_count * retry_interval) - - def _actor_acct_uri_to_userid(self, actor_acct_uri: str) -> str: """ The algorithm by which this application maps userids to ActivityPub actor handles in reverse. @@ -749,8 +666,9 @@ def create_configuration_account_manager(self, rolename: str, test_plan_node: Te verify_tls_certificate = test_plan_node.parameter_or_raise(VERIFY_API_TLS_CERTIFICATE_PAR, { VERIFY_API_TLS_CERTIFICATE_PAR.name: 'true' }) if not hostname: - hostname = prompt_user(f'Enter the hostname for the Mastodon Node of constellation role "{ rolename }" (node parameter "hostname"): ', - parse_validate=hostname_validate) + hostname = prompt_user_parse_validate( + f'Enter the hostname for the Mastodon Node of constellation role "{ rolename }" (node parameter "hostname"): ', + parse_validate=hostname_validate) accounts : list[Account] = [] if test_plan_node.accounts: diff --git a/src/feditest/nodedrivers/wordpress/__init__.py b/src/feditest/nodedrivers/wordpress/__init__.py index 89e53fe..60713d2 100644 --- a/src/feditest/nodedrivers/wordpress/__init__.py +++ b/src/feditest/nodedrivers/wordpress/__init__.py @@ -31,7 +31,7 @@ ) from feditest.reporting import is_trace_active, trace from feditest.testplan import TestPlanConstellationNode, TestPlanNodeAccountField, TestPlanNodeNonExistingAccountField, TestPlanNodeParameter -from feditest.utils import boolean_parse_validate, hostname_validate, prompt_user +from feditest.utils import boolean_parse_validate, hostname_validate, prompt_user_parse_validate VERIFY_API_TLS_CERTIFICATE_PAR = TestPlanNodeParameter( @@ -115,9 +115,9 @@ class WordPressPlusPluginsNode(NodeWithMastodonAPI): A Node running WordPress with the ActivityPub plugin. """ def _provision_oauth_token_for(self, account: WordPressAccount, oauth_client_id: str) -> str: - ret = cast(str, prompt_user(f'Enter the OAuth token for the Mastodon API for user "{ account.userid }"' - + f' on constellation role "{ self.rolename }", OAuth client id "{ oauth_client_id }" (user field "{ OAUTH_TOKEN_ACCOUNT_FIELD }"): ', - parse_validate=_oauth_token_validate)) + ret = prompt_user_parse_validate(f'Enter the OAuth token for the Mastodon API for user "{ account.userid }"' + + f' on constellation role "{ self.rolename }", OAuth client id "{ oauth_client_id }" (user field "{ OAUTH_TOKEN_ACCOUNT_FIELD }"): ', + parse_validate=_oauth_token_validate) return ret @@ -164,9 +164,9 @@ def create_configuration_account_manager(self, rolename: str, test_plan_node: Te verify_tls_certificate = test_plan_node.parameter_or_raise(VERIFY_API_TLS_CERTIFICATE_PAR, { VERIFY_API_TLS_CERTIFICATE_PAR.name: 'true' }) if not hostname: - hostname = prompt_user(f'Enter the hostname for the WordPress+plugins Node of constellation role "{ rolename }"' - + f' (node parameter "{ HOSTNAME_PAR }"): ', - parse_validate=hostname_validate) + hostname = prompt_user_parse_validate(f'Enter the hostname for the WordPress+plugins Node of constellation role "{ rolename }"' + + f' (node parameter "{ HOSTNAME_PAR }"): ', + parse_validate=hostname_validate) accounts : list[Account] = [] if test_plan_node.accounts: diff --git a/src/feditest/protocols/activitypub/utils.py b/src/feditest/protocols/activitypub/utils.py index d73e05a..5517fdd 100644 --- a/src/feditest/protocols/activitypub/utils.py +++ b/src/feditest/protocols/activitypub/utils.py @@ -2,13 +2,13 @@ ActivityPub testing utils """ -from typing import Any, cast +from typing import Any from hamcrest.core.base_matcher import BaseMatcher from hamcrest.core.description import Description from feditest.nodedrivers import Node -from feditest.utils import boolean_response_parse_validate, prompt_user +from feditest.utils import boolean_response_parse_validate, prompt_user_parse_validate class MemberOfCollectionMatcher(BaseMatcher[Any]): @@ -24,10 +24,10 @@ def __init__(self, collection_uri: str, node: Node): def _matches(self, member_candidate_uri: str) -> bool: - ret = prompt_user( + ret = prompt_user_parse_validate( f'Is "{ member_candidate_uri }" a member of the collection at URI "{ self._collection_uri }"? ', parse_validate=boolean_response_parse_validate) - return cast(bool, ret) + return ret def describe_to(self, description: Description) -> None: diff --git a/src/feditest/protocols/fediverse/__init__.py b/src/feditest/protocols/fediverse/__init__.py index 4262385..5e93e4f 100644 --- a/src/feditest/protocols/fediverse/__init__.py +++ b/src/feditest/protocols/fediverse/__init__.py @@ -16,7 +16,7 @@ def userid_validate(candidate: str) -> str | None: userpart of https://datatracker.ietf.org/doc/html/rfc7565 """ candidate = candidate.strip() - return candidate if re.fullmatch(r'[-\.~a-zA-Z0-9_!$&''()\*\+,;=]([-\.~a-zA-Z0-9_!$&''()*+,;=]|%[0-9a-fA-F]{2})*', candidate) else None + return candidate if re.fullmatch(r'[-\.~a-zA-Z0-9_!$&''()*+,;=]([-\.~a-zA-Z0-9_!$&''()*+,;=]|%[0-9a-fA-F]{2})*', candidate) else None ROLE_ACCOUNT_FIELD = TestPlanNodeAccountField( @@ -205,49 +205,17 @@ def make_follow_undo(self, actor_acct_uri: str, following_actor_acct_uri: str) - raise NotImplementedByNodeError(self, FediverseNode.make_follow_undo) - def wait_until_actor_has_received_note(self, actor_acct_uri: str, object_uri: str, max_wait: float = 5.) -> str: + def actor_has_received_note(self, actor_acct_uri: str, object_uri: str) -> str | None: """ - Wait until the object at object_uri has arrived with the Actor at actor_acct_uri. - This method does not state that the object needs to have arrived in the Actor's, inbox, - as Nodes might implement different routing strategies (including antispam). - If the condition has not arrived by the time max_wait seconds have passed, throw - Return value: the content of the Note - a TimeoutException. + If the note at object_uri has arrived with the Actor at actor_acct_uri, return the content + of the note. """ - raise NotImplementedByNodeError(self, FediverseNode.wait_until_actor_has_received_note) + raise NotImplementedByNodeError(self, FediverseNode.actor_has_received_note) - def wait_until_actor_is_following_actor(self, actor_acct_uri: str, to_be_followed_uri: str, max_wait: float = 5.) -> None: + def actor_is_following_actor(self, actor_acct_uri: str, leader_actor_acct_uri: str) -> bool: """ - Wait until the Actor at actor_acct_uri is following the Actor at to_be_followed_uri on this Node. - If the condition has not arrived by the time max_wait seconds have passed, throw - a TimeoutException. + Return True if the Actor at actor_acct_uri is following the Actor at leader_actor_acct_uri, + in the opinion of this Node. """ - raise NotImplementedByNodeError(self, FediverseNode.wait_until_actor_is_following_actor) - - - def wait_until_actor_is_followed_by_actor(self, actor_acct_uri: str, to_be_following_uri: str, max_wait: float = 5.) -> None: - """ - Wait until the Actor at actor_acct_uri is being followed by the Actor with to_be_following_uri on this Node. - If the condition has not arrived by the time max_wait seconds have passed, throw - a TimeoutException. - """ - raise NotImplementedByNodeError(self, FediverseNode.wait_until_actor_is_followed_by_actor) - - - def wait_until_actor_is_unfollowing_actor(self, actor_acct_uri: str, to_be_unfollowed_uri: str, max_wait: float = 5.) -> None: - """ - Wait until the Actor at actor_acct_uri has ceased following the Actor at to_be_unfollowed_uri on this Node. - If the condition has not arrived by the time max_wait seconds have passed, throw - a TimeoutException. - """ - raise NotImplementedByNodeError(self, FediverseNode.wait_until_actor_is_unfollowing_actor) - - - def wait_until_actor_is_unfollowed_by_actor(self, actor_acct_uri: str, to_be_unfollowing_uri: str, max_wait: float = 5.) -> None: - """ - Wait until the Actor at actor_acct_uri has ceased following the Actor with to_be_unfollowing_uri on this Node. - If the condition has not arrived by the time max_wait seconds have passed, throw - a TimeoutException. - """ - raise NotImplementedByNodeError(self, FediverseNode.wait_until_actor_is_unfollowed_by_actor) + raise NotImplementedByNodeError(self, FediverseNode.actor_is_following_actor) diff --git a/src/feditest/utils.py b/src/feditest/utils.py index 838e182..5705b55 100644 --- a/src/feditest/utils.py +++ b/src/feditest/utils.py @@ -9,6 +9,7 @@ import re import sys import importlib.metadata +import time from types import ModuleType from typing import Any, Callable, List, Optional, TypeVar from urllib.parse import ParseResult, parse_qs, urlparse @@ -459,7 +460,35 @@ def boolean_response_parse_validate(candidate:str) -> bool | None: return None +class TimeoutException(RuntimeError): + """ + A result has not arrived within the expected time period. + """ + def __init__(self, msg: str, timeout: float): + super().__init__(f'{ msg } (timeout: { timeout })') + + T = TypeVar('T') + +def poll_until( + condition: Callable[[], T | None], + retry_count: int = 5, + retry_interval: float = 1.0, + msg: str | None = None +) -> T: + """ + Keep invoking condition() until it returns a non-None value or it times out. + """ + for _ in range(retry_count): + response = condition() + if response: + return response + time.sleep(retry_interval) + if not msg: + msg = 'Expected object has not arrived in time' + raise TimeoutException(msg, retry_count * retry_interval) + + def find_first_in_array(array: List[T], condition: Callable[[T], bool]) -> T | None: """ IMHO this should be a python built-in function. The next() workaround confuses me more than I like. @@ -505,36 +534,29 @@ def format_name_value_string(data: dict[str,str | None]) -> str: return ret -def prompt_user(question: str, value_if_known: Any | None = None, parse_validate: Callable[[str],Any] | None = None) -> Any | None: +def prompt_user_parse_validate(question: str, parse_validate: Callable[[str],T | None]) -> T: """ - If an NodeDriver does not natively implement support for a particular method, - this method is invoked as a fallback. It prompts the user to enter information - at the console. - - This is implemented on NodeDriver rather than Node, so we can also ask - provisioning-related questions. + Prompt the user to enter a text string at the console. Parse/validate the entered + String, and keep asking until validation passes. Return the parsed string. question: the text to be emitted to the user as a prompt - value_if_known: if given, that value can be used instead of asking the user - parse_validate: optional function that attempts to parse and validate the provided user input. - If the value is valid, it parses the value and returns the parsed version. If not valid, it returns None. - return: the value entered by the user, parsed, or None - """ - if value_if_known: - if parse_validate is None: - return value_if_known - ret_parsed = parse_validate(value_if_known) - if ret_parsed is not None: - return ret_parsed - warning(f'Preconfigured value "{ value_if_known }" is invalid, ignoring.') - + parse_validate: function that attempts to parse and validate the provided user input. + return: the value entered by the user (parsed) + """ while True: ret = input(f'TESTER ACTION REQUIRED: { question }') - if parse_validate is None: - return ret ret_parsed = parse_validate(ret) if ret_parsed is not None: return ret_parsed print(f'INPUT ERROR: invalid input, try again. Was: "{ ret }"') +def prompt_user(question: str) -> str: + """ + Prompt the user to enter a text string at the console. + + question: the text to be emitted to the user as a prompt + return: the value entered by the user + """ + ret = input(f'TESTER ACTION REQUIRED: { question }') + return ret diff --git a/tests.smoke/tests/node_with_mastodon_api.py b/tests.smoke/tests/node_with_mastodon_api.py index 993b221..361f94f 100644 --- a/tests.smoke/tests/node_with_mastodon_api.py +++ b/tests.smoke/tests/node_with_mastodon_api.py @@ -9,6 +9,7 @@ from feditest import step, test from feditest.nodedrivers.mastodon import NodeWithMastodonAPI +from feditest.utils import poll_until # @test # def app_version( @@ -51,7 +52,7 @@ def create_note(self): @step def wait_for_note_in_inbox(self): - self.server.wait_until_actor_has_received_note(self.actor_acct_uri, self.note_uri) + poll_until(lambda: self.server.actor_has_received_note(self.actor_acct_uri, self.note_uri)) # @step diff --git a/tests.smoke/tests/nodes_with_mastodon_api_communicate.py b/tests.smoke/tests/nodes_with_mastodon_api_communicate.py index de66285..a367daf 100644 --- a/tests.smoke/tests/nodes_with_mastodon_api_communicate.py +++ b/tests.smoke/tests/nodes_with_mastodon_api_communicate.py @@ -7,6 +7,7 @@ from feditest import step, test from feditest.nodedrivers.mastodon import NodeWithMastodonAPI +from feditest.utils import poll_until @test @@ -49,12 +50,12 @@ def wait_until_actor_is_followed_by_actor(self): time.sleep(1) # Sometimes there seems to be a race condition in Mastodon, or something like that. # If we proceed too quickly, the API returns 422 "User already exists" or such # in response to a search, which makes no sense. - self.leader_node.wait_until_actor_is_followed_by_actor(self.leader_actor_acct_uri, self.follower_actor_acct_uri) + poll_until(lambda: self.leader_node.actor_is_followed_by_actor(self.leader_actor_acct_uri, self.follower_actor_acct_uri)) @step def wait_until_actor_is_following_actor(self): - self.follower_node.wait_until_actor_is_following_actor(self.follower_actor_acct_uri, self.leader_actor_acct_uri) + poll_until(lambda: self.follower_node.actor_is_following_actor(self.follower_actor_acct_uri, self.leader_actor_acct_uri)) @step @@ -65,7 +66,7 @@ def leader_creates_note(self): @step def wait_until_note_received(self): - self.follower_node.wait_until_actor_has_received_note(self.follower_actor_acct_uri, self.leader_note_uri) + poll_until(lambda: self.follower_node.actor_has_received_note(self.follower_actor_acct_uri, self.leader_note_uri)) # @step From 0f1e2c4bb90cc863f3c9d88b4e17b624a1b843a3 Mon Sep 17 00:00:00 2001 From: Johannes Ernst Date: Tue, 15 Oct 2024 14:24:28 -0700 Subject: [PATCH 2/2] Add feditest.disabled for easy commenting out of tests --- src/feditest/disabled.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/feditest/disabled.py diff --git a/src/feditest/disabled.py b/src/feditest/disabled.py new file mode 100644 index 0000000..7529734 --- /dev/null +++ b/src/feditest/disabled.py @@ -0,0 +1,26 @@ +# This is a little hack, but useful. +# The problem: +# You have a file that contains some @tests and maybe some @steps inside @tests. You want to temporarily disable them. +# You can go through the file, and change all the @test and @step annotations. +# Or, you can change the import statement from something like: +# from feditest import AssertionFailure, InteropLevel, SpecLevel, step, test +# to +# from feditest.disabled import AssertionFailure, InteropLevel, SpecLevel, step, test +# + +from typing import Callable + +from feditest import AssertionFailure, InteropLevel, SpecLevel, all_node_drivers, all_tests, assert_that, nodedriver # noqa: F401 + +def test(to_register: Callable[..., None] | type) -> Callable[..., None] | type: + """ + Disabled: do nothing + """ + return to_register + + +def step(to_register: Callable[..., None]) -> Callable[..., None]: + """ + Disabled: do nothing + """ + return to_register \ No newline at end of file