feat: added better logging and finished activ test
Better logging is provided with rich lib and using the logging module Activity tab test has better error handling and hardcoded loop for CID's
This commit is contained in:
parent
364663ab66
commit
f37625580b
5
Pipfile
5
Pipfile
@ -6,10 +6,9 @@ name = "pypi"
|
|||||||
[packages]
|
[packages]
|
||||||
selenium = "*"
|
selenium = "*"
|
||||||
requests = "*"
|
requests = "*"
|
||||||
|
rich = "*"
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
pylint = "*"
|
pylint = "*"
|
||||||
yapf = "*"
|
yapf = "*"
|
||||||
|
rope = "*"
|
||||||
[requires]
|
|
||||||
python_version = "3.9"
|
|
||||||
|
211
main.py
211
main.py
@ -1,20 +1,33 @@
|
|||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import csv
|
|
||||||
import argparse
|
|
||||||
import urllib.parse as urlparse
|
import urllib.parse as urlparse
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from rich.logging import RichHandler
|
||||||
|
from rich.console import Console
|
||||||
from selenium import webdriver
|
from selenium import webdriver
|
||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
from selenium.webdriver.common.keys import Keys
|
from selenium.webdriver.common.keys import Keys
|
||||||
from selenium.webdriver.support import expected_conditions as EC
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
from selenium.webdriver.support.select import Select
|
||||||
|
|
||||||
|
|
||||||
class BaseTester:
|
class BaseTester:
|
||||||
|
"""Base class for all test suites"""
|
||||||
def __init__(self, user: str, passwd: str, dev: bool, show_browser: bool):
|
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.user = user
|
||||||
self.passwd = passwd
|
self.passwd = passwd
|
||||||
if dev:
|
if dev:
|
||||||
@ -27,9 +40,54 @@ class BaseTester:
|
|||||||
self.browser = webdriver.Firefox(options=firefox_options)
|
self.browser = webdriver.Firefox(options=firefox_options)
|
||||||
self.wait = WebDriverWait(self.browser, 20)
|
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):
|
def login(self):
|
||||||
""" Login to civicrm so we can continue with the proper cookies """
|
""" Login to civicrm so we can continue with the proper cookies """
|
||||||
self.browser.get(self.base_url)
|
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")
|
username = self.browser.find_element_by_id("edit-name")
|
||||||
password = self.browser.find_element_by_id("edit-pass")
|
password = self.browser.find_element_by_id("edit-pass")
|
||||||
submit = self.browser.find_element_by_id("edit-submit")
|
submit = self.browser.find_element_by_id("edit-submit")
|
||||||
@ -42,8 +100,10 @@ class BaseTester:
|
|||||||
self.wait.until(
|
self.wait.until(
|
||||||
EC.visibility_of_element_located((By.ID, "block-civicrm-2"))
|
EC.visibility_of_element_located((By.ID, "block-civicrm-2"))
|
||||||
)
|
)
|
||||||
|
self.debug("Successfully logged in as %s" % self.user)
|
||||||
|
|
||||||
def logout(self):
|
def logout(self):
|
||||||
|
"""Log browser out of civicrm to reset our test environment"""
|
||||||
self.browser.get(self.base_url + "/user/logout")
|
self.browser.get(self.base_url + "/user/logout")
|
||||||
# Wait for the next page to load to finish logging out
|
# Wait for the next page to load to finish logging out
|
||||||
self.wait.until(
|
self.wait.until(
|
||||||
@ -51,18 +111,30 @@ class BaseTester:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def find_element_by_id(self, *args, **kwargs):
|
def find_element_by_id(self, *args, **kwargs):
|
||||||
|
"""Alias for browser.find_element_by_id"""
|
||||||
return self.browser.find_element_by_id(*args, **kwargs)
|
return self.browser.find_element_by_id(*args, **kwargs)
|
||||||
|
|
||||||
def find_element_by_css_selector(self, *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)
|
return self.browser.find_element_by_css_selector(*args, **kwargs)
|
||||||
|
|
||||||
def wait_until_visible(self, locator):
|
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))
|
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):
|
class ContactExport(BaseTester):
|
||||||
|
"""Tests if exporting contacts from a search returns a CSV file with all contacts."""
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*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.search_url = self.base_url + "/civicrm/contact/search"
|
||||||
|
|
||||||
self.contact_selectall_id = "CIVICRM_QFID_ts_all_4"
|
self.contact_selectall_id = "CIVICRM_QFID_ts_all_4"
|
||||||
@ -70,12 +142,18 @@ class ContactExport(BaseTester):
|
|||||||
self.contact_exportoption_id = "select2-result-label-15"
|
self.contact_exportoption_id = "select2-result-label-15"
|
||||||
|
|
||||||
def download_csv(self):
|
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 = {}
|
session_cookie = {}
|
||||||
for cookie in self.browser.get_cookies():
|
for cookie in self.browser.get_cookies():
|
||||||
if re.findall(r"^SSESS.*", cookie.get("name")):
|
if re.findall(r"^SSESS.*", cookie.get("name")):
|
||||||
session_cookie = cookie
|
session_cookie = cookie
|
||||||
|
self.debug("session cookie: %s" % session_cookie)
|
||||||
if not 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.")
|
raise RuntimeError("No session cookie found.")
|
||||||
|
|
||||||
session = requests.Session()
|
session = requests.Session()
|
||||||
@ -83,6 +161,7 @@ class ContactExport(BaseTester):
|
|||||||
{session_cookie["name"]: session_cookie["value"]}
|
{session_cookie["name"]: session_cookie["value"]}
|
||||||
)
|
)
|
||||||
qf_key = self.get_export_id()
|
qf_key = self.get_export_id()
|
||||||
|
# Most of this data is replicating the export settings so that the response is the csv data
|
||||||
data = {
|
data = {
|
||||||
"qfKey": qf_key,
|
"qfKey": qf_key,
|
||||||
"entryURL": self.base_url + "/civicrm/contact/search",
|
"entryURL": self.base_url + "/civicrm/contact/search",
|
||||||
@ -92,27 +171,40 @@ class ContactExport(BaseTester):
|
|||||||
"postal_greeting": 1,
|
"postal_greeting": 1,
|
||||||
"addressee": 1,
|
"addressee": 1,
|
||||||
}
|
}
|
||||||
req = session.request(
|
res = session.request(
|
||||||
"POST", self.base_url + "/civicrm/contact/search", data=data
|
"POST", self.base_url + "/civicrm/contact/search", data=data
|
||||||
)
|
)
|
||||||
return req
|
return res
|
||||||
|
|
||||||
def calculate_exported_contacts_number(self):
|
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"
|
file_name = "/tmp/exportedRecords.csv"
|
||||||
with open(file_name, "w") as csv_file:
|
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:
|
with open(file_name, "r") as csv_file:
|
||||||
# Dict reader should remove headers
|
# Dict reader should remove headers
|
||||||
exported_csv = csv.DictReader(csv_file)
|
exported_csv = csv.DictReader(csv_file)
|
||||||
exported_number_exports = sum(1 for row in exported_csv)
|
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)
|
os.remove(file_name)
|
||||||
return exported_number_exports
|
return exported_number_exports
|
||||||
|
|
||||||
def get_export_id(self) -> str:
|
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
|
export_page_url = self.browser.current_url
|
||||||
parsed = urlparse.urlparse(export_page_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):
|
def test(self, search_term: str):
|
||||||
try:
|
try:
|
||||||
@ -121,9 +213,11 @@ class ContactExport(BaseTester):
|
|||||||
search_box = self.find_element_by_id("sort_name")
|
search_box = self.find_element_by_id("sort_name")
|
||||||
search_box.send_keys(search_term)
|
search_box.send_keys(search_term)
|
||||||
search_box.send_keys(Keys.ENTER)
|
search_box.send_keys(Keys.ENTER)
|
||||||
|
self.debug("searching for contacts with term '%s'" % search_term)
|
||||||
self.wait_until_visible(
|
self.wait_until_visible(
|
||||||
(By.ID, "alpha-filter")
|
(By.ID, "alpha-filter")
|
||||||
) #wait for table to load
|
) #wait for table to load
|
||||||
|
self.debug("table of results has loaded")
|
||||||
|
|
||||||
results_text = self.find_element_by_css_selector(
|
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)"
|
".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
|
) # Should just be one match in normal cases
|
||||||
result_no = int(matches[0])
|
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_selectall_id).click()
|
||||||
self.find_element_by_id(self.contact_dropdown_id).click()
|
self.find_element_by_id(self.contact_dropdown_id).click()
|
||||||
self.find_element_by_id(self.contact_exportoption_id).click()
|
self.find_element_by_id(self.contact_exportoption_id).click()
|
||||||
self.wait_until_visible((By.CSS_SELECTOR, ".crm-block"))
|
self.wait_until_visible((By.CSS_SELECTOR, ".crm-block"))
|
||||||
|
|
||||||
req = self.download_csv()
|
|
||||||
exported_number_exports = self.calculate_exported_contacts_number()
|
exported_number_exports = self.calculate_exported_contacts_number()
|
||||||
if exported_number_exports == (result_no):
|
if exported_number_exports == (result_no):
|
||||||
print(
|
self.passed(
|
||||||
"TEST PASSED: Number of expected contact exports for '{}' matches actual number of exports - Expected: {}, Actual: {}"
|
"Number of expected contact exports for '%s' matches actual number of exports - Expected: %d, Actual: %d"
|
||||||
.format(search_term, result_no, exported_number_exports)
|
% (search_term, result_no, exported_number_exports)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
print(
|
self.failed(
|
||||||
"TEST FAILED: Number of expected contact exports for '{}' WAS NOT EQUAL to actual number of exports - Expected: {}, Actual: {}"
|
"Number of expected contact exports for '%s' WAS NOT EQUAL to actual number of exports - Expected: %d, Actual: %d"
|
||||||
.format(search_term, result_no, exported_number_exports)
|
% (search_term, result_no, exported_number_exports)
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
self.logout()
|
self.logout()
|
||||||
self.browser.close()
|
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__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser(description="")
|
parser = argparse.ArgumentParser(description="")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@ -179,4 +342,20 @@ if __name__ == "__main__":
|
|||||||
)
|
)
|
||||||
parser.set_defaults(dev=False, show_browser=False)
|
parser.set_defaults(dev=False, show_browser=False)
|
||||||
arguments = parser.parse_args()
|
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
|
||||||
|
Loading…
Reference in New Issue
Block a user