diff --git a/Pipfile b/Pipfile index ec3362c..ad2fc0f 100644 --- a/Pipfile +++ b/Pipfile @@ -6,10 +6,9 @@ name = "pypi" [packages] selenium = "*" requests = "*" +rich = "*" [dev-packages] pylint = "*" yapf = "*" - -[requires] -python_version = "3.9" +rope = "*" diff --git a/main.py b/main.py index f4a49ca..ab9902d 100644 --- a/main.py +++ b/main.py @@ -1,20 +1,33 @@ +import argparse +import csv +import logging import os import re -import csv -import argparse import urllib.parse as urlparse from urllib.parse import parse_qs import requests +from rich.logging import RichHandler +from rich.console import Console from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support.select import Select class BaseTester: + """Base class for all test suites""" def __init__(self, user: str, passwd: str, dev: bool, show_browser: bool): + self.console = Console() + logging.basicConfig( + level="INFO", + format="%(message)s", + datefmt="[%X]", + handlers=[RichHandler()] + ) + self.log = logging.getLogger("civiCRM-tester") self.user = user self.passwd = passwd if dev: @@ -27,9 +40,54 @@ class BaseTester: self.browser = webdriver.Firefox(options=firefox_options) self.wait = WebDriverWait(self.browser, 20) + def debug(self, msg: str): + """Wrapper for logging debug levels for rich output""" + return self.log.debug(msg, extra={"markup": True}) + + def info(self, msg: str): + """Wrapper for logging info levels for rich output""" + return self.log.info(msg, extra={"markup": True}) + + def warn(self, msg: str): + """Wrapper for logging warn levels for rich output""" + return self.log.warning(msg, extra={"markup": True}) + + def error(self, msg: str): + """Wrapper for logging error levels for rich output""" + return self.log.error(msg, extra={"markup": True}) + + def critical(self, msg: str): + """Wrapper for logging critical levels for rich output""" + return self.log.critical(msg, extra={"markup": True}) + + def passed(self, msg: str): + """Wrapper for logging passed tests""" + return self.info("[reverse green]PASSED[/reverse green] %s" % msg) + + def issue(self, msg: str): + """ + Wrapper for logging tests with issues + This is used for tests where the result is inconclusive due to some issue that is neither a fail nor pass. + Usually requires human interaction + """ + return self.warn("[reverse yellow]ISSUE[/reverse yellow] %s" % msg) + + def failed(self, msg: str): + """Wrapper for logging failed tests""" + return self.info("[reverse red]FAILED[/reverse red] %s" % msg) + + def title(self, test_name: str): + """Create title header for new suite of tests""" + return self.info("[cyan]%s[/cyan]" % test_name) + + def desc(self, test_desc: str): + """Log test description of upcoming number of tests""" + return self.info("%s" % test_desc) + def login(self): """ Login to civicrm so we can continue with the proper cookies """ self.browser.get(self.base_url) + self.debug("Logging in as %s @ %s" % (self.user, self.base_url)) username = self.browser.find_element_by_id("edit-name") password = self.browser.find_element_by_id("edit-pass") submit = self.browser.find_element_by_id("edit-submit") @@ -42,8 +100,10 @@ class BaseTester: self.wait.until( EC.visibility_of_element_located((By.ID, "block-civicrm-2")) ) + self.debug("Successfully logged in as %s" % self.user) def logout(self): + """Log browser out of civicrm to reset our test environment""" self.browser.get(self.base_url + "/user/logout") # Wait for the next page to load to finish logging out self.wait.until( @@ -51,18 +111,30 @@ class BaseTester: ) def find_element_by_id(self, *args, **kwargs): + """Alias for browser.find_element_by_id""" return self.browser.find_element_by_id(*args, **kwargs) def find_element_by_css_selector(self, *args, **kwargs): + """Alias for browser.find_element_by_css_selector""" return self.browser.find_element_by_css_selector(*args, **kwargs) def wait_until_visible(self, locator): + """Alias for using inbuilt wait object for wait.until(EC.visibility_of_element_located)""" return self.wait.until(EC.visibility_of_element_located(locator)) + def wait_until_clickable(self, locator): + """Alias for using inbuilt wait object for wait.until(EC.element_to_be_clickable)""" + return self.wait.until(EC.element_to_be_clickable(locator)) + class ContactExport(BaseTester): + """Tests if exporting contacts from a search returns a CSV file with all contacts.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.title("Contact Export") + self.desc( + "Testing if exporting contacts from a search returns a CSV file with all contacts." + ) self.search_url = self.base_url + "/civicrm/contact/search" self.contact_selectall_id = "CIVICRM_QFID_ts_all_4" @@ -70,12 +142,18 @@ class ContactExport(BaseTester): self.contact_exportoption_id = "select2-result-label-15" def download_csv(self): + """ + Download export CSV manually using requests + This is because managing downloads within Selenium is a nightmare + Function tries to replicate the download within the browser as much as possible + """ session_cookie = {} for cookie in self.browser.get_cookies(): if re.findall(r"^SSESS.*", cookie.get("name")): session_cookie = cookie + self.debug("session cookie: %s" % session_cookie) if not session_cookie: - print("NO SESSION COOKIE FOUND. Are you logged in?") + self.critical("NO SESSION COOKIE FOUND. Are you logged in?") raise RuntimeError("No session cookie found.") session = requests.Session() @@ -83,6 +161,7 @@ class ContactExport(BaseTester): {session_cookie["name"]: session_cookie["value"]} ) qf_key = self.get_export_id() + # Most of this data is replicating the export settings so that the response is the csv data data = { "qfKey": qf_key, "entryURL": self.base_url + "/civicrm/contact/search", @@ -92,27 +171,40 @@ class ContactExport(BaseTester): "postal_greeting": 1, "addressee": 1, } - req = session.request( + res = session.request( "POST", self.base_url + "/civicrm/contact/search", data=data ) - return req + return res def calculate_exported_contacts_number(self): - req = self.download_csv() + """ + Downloads csv, saves it to tmp, opens it and checks if the number of contacts is equal to the reported number of contacts within the web ui + Has to be saved to disk due to the csv lib only wanting a file object, not str + """ + res = self.download_csv() file_name = "/tmp/exportedRecords.csv" with open(file_name, "w") as csv_file: - csv_file.write(req.text) + self.debug("writing csv to '%s'" % file_name) + csv_file.write(res.text) with open(file_name, "r") as csv_file: # Dict reader should remove headers exported_csv = csv.DictReader(csv_file) exported_number_exports = sum(1 for row in exported_csv) + self.debug( + "found %d rows in exported csv excluding header row" % + exported_number_exports + ) + self.debug("deleting '%s'" % file_name) os.remove(file_name) return exported_number_exports def get_export_id(self) -> str: + """Parses url to get the param used to ID what search we are currently doing""" export_page_url = self.browser.current_url parsed = urlparse.urlparse(export_page_url) - return parse_qs(parsed.query)['qfKey'] + qf_key = parse_qs(parsed.query)['qfKey'] + self.debug("got qf_key '%s' from url" % qf_key) + return qf_key def test(self, search_term: str): try: @@ -121,9 +213,11 @@ class ContactExport(BaseTester): search_box = self.find_element_by_id("sort_name") search_box.send_keys(search_term) search_box.send_keys(Keys.ENTER) + self.debug("searching for contacts with term '%s'" % search_term) self.wait_until_visible( (By.ID, "alpha-filter") ) #wait for table to load + self.debug("table of results has loaded") results_text = self.find_element_by_css_selector( ".form-layout-compressed > tbody:nth-child(1) > tr:nth-child(2) > td:nth-child(2) > label:nth-child(2)" @@ -133,28 +227,97 @@ class ContactExport(BaseTester): ) # Should just be one match in normal cases result_no = int(matches[0]) + self.debug("exporting results using the magic dropdown") self.find_element_by_id(self.contact_selectall_id).click() self.find_element_by_id(self.contact_dropdown_id).click() self.find_element_by_id(self.contact_exportoption_id).click() self.wait_until_visible((By.CSS_SELECTOR, ".crm-block")) - req = self.download_csv() exported_number_exports = self.calculate_exported_contacts_number() if exported_number_exports == (result_no): - print( - "TEST PASSED: Number of expected contact exports for '{}' matches actual number of exports - Expected: {}, Actual: {}" - .format(search_term, result_no, exported_number_exports) + self.passed( + "Number of expected contact exports for '%s' matches actual number of exports - Expected: %d, Actual: %d" + % (search_term, result_no, exported_number_exports) ) else: - print( - "TEST FAILED: Number of expected contact exports for '{}' WAS NOT EQUAL to actual number of exports - Expected: {}, Actual: {}" - .format(search_term, result_no, exported_number_exports) + self.failed( + "Number of expected contact exports for '%s' WAS NOT EQUAL to actual number of exports - Expected: %d, Actual: %d" + % (search_term, result_no, exported_number_exports) ) finally: self.logout() self.browser.close() +class ActivitiesTab(BaseTester): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.title("Activities Tab") + self.desc("Testing if a contacts activities tab displays all activies") + self.contact_page = self.base_url + "/civicrm/contact/view/?reset=1&cid={}" + + def test(self, cid: str): + self.debug("loading contact page for CID %s" % cid) + self.browser.get(self.contact_page.format(cid)) + self.wait_until_visible((By.ID, "ui-id-10")) + # Contact page as loaded + activities_tab_button = self.find_element_by_id("ui-id-10") + num_element = activities_tab_button.find_element_by_tag_name("em") + num_of_activ = int(num_element.text) + activities_tab_button.click() + table_row_selector = ( + By.CSS_SELECTOR, "#DataTables_Table_0 > tbody tr" + ) + self.debug( + "clicked activities button and waiting for activities page to load" + ) + self.wait_until_visible(table_row_selector) + # Activities page as now loaded + table_length_dropdown = Select( + self.browser.find_element_by_xpath( + "//select[@name='DataTables_Table_0_length']" + ) + ) + table_length_dropdown.select_by_visible_text("100") + self.wait_until_clickable(table_row_selector) + num_of_rows = len( + self.browser.find_elements_by_css_selector("tr.crm-entity") + ) + if num_of_activ == num_of_rows: + self.passed( + "expected number of activities found in activity table for CID %s. Expected: %d, Actual %d" + % (cid, num_of_activ, num_of_rows) + ) + elif num_of_activ > 100 and num_of_rows == 100: + self.issue( + "Number of activities for CID %s is above 100. This is the max amount the table can display. The table is displaying 100 entries. Pagination is not supported by the script." + % cid + ) + else: + self.failed( + "Number of activities is lower than expected. Most likely didn't load properly Expected: %d, Actual %d" + % (cid, num_of_activ, num_of_rows) + ) + + def test_all_hardcoded_contacts(self): + """Loops self.test with all hardcoded contact ID's + Using MP's as MP's are public knowledge and often have activities within the crm system + names provided in comments here for debugging purposes + """ + cid_da = "42219" # Debbie Abrahams + cid_kh = "82163" # Kate Hollern + cid_na = "42269" # Nigel Addams + #cid_db = "43193" Use to test 100 max limit + cid_tuple = (cid_da, cid_kh, cid_na) + try: + self.login() + for cid in cid_tuple: + self.test(cid) + finally: + self.logout() + self.browser.close() + + if __name__ == "__main__": parser = argparse.ArgumentParser(description="") parser.add_argument( @@ -179,4 +342,20 @@ if __name__ == "__main__": ) parser.set_defaults(dev=False, show_browser=False) arguments = parser.parse_args() - ContactExport(arguments.user, arguments.passwd, arguments.dev, arguments.show_browser).test("John") + ActivitiesTab( + arguments.user, arguments.passwd, arguments.dev, arguments.show_browser + ).test_all_hardcoded_contacts() + ContactExport( + arguments.user, arguments.passwd, arguments.dev, arguments.show_browser + ).test("John") + #ContactExport(arguments.user, arguments.passwd, arguments.dev).test("e") + +# Mailing list +# Load mailing list test and enter test data +# send test email and check for soft crashes +# manual check the email sent + +# Print labels +# Search contacts > by group "Steering Committee" > search +# Print labels for group, untick "do not mail" check box +# Resulting pdf might have missing names