modules/page_object_autofill.py (504 lines of code) (raw):
import json
import logging
from typing import List, Optional
from selenium.webdriver import Firefox
from selenium.webdriver.support.select import Select
from modules.browser_object_autofill_popup import AutofillPopup
from modules.classes.autofill_base import AutofillAddressBase
from modules.classes.credit_card import CreditCardBase
from modules.page_base import BasePage
from modules.util import BrowserActions, Utilities
BASE_CC_URL_TEMPLATE = "https://mozilla.github.io/form-fill-examples/basic_cc.html"
BASE_ADDRESS_URL_TEMPLATE = "https://mozilla.github.io/form-fill-examples/basic.html"
# Class Attributes: Element Selectors
base_cc_field_mapping = {
"name": "cc-name",
"card_number": "cc-number",
"expiration_month": "cc-exp-month",
"expiration_year": "cc-exp-year",
"cvv": "cc-csc",
}
base_address_field_mapping = {
"name": "name",
"organization": "organization",
"street_address": "street-address",
"address_level_2": "address-level2",
"address_level_1": "address-level1",
"postal_code": "postal-code",
"country_code": "country",
"email": "email",
"telephone": "tel",
}
# Element Selectors
base_cc_fields = ["cc-name", "cc-number", "cc-exp-month", "cc-exp-year"]
base_address_fields = [
"name",
"organization",
"street-address",
"address-level2", # city
"address-level1", # state/province
"postal-code",
"country",
"email",
"tel",
]
# Preview field mappings
base_preview_address_mapping = {
"family_name": "family-name",
"given_name": "given-name",
"street_address": "street-address",
"address_level_2": "address-level2",
"address_level_1": "address-level1",
"postal_code": "postal-code",
"country_code": "country",
"telephone": "tel",
}
base_preview_cc_mapping = {
"name": "cc-name",
"card_number": "cc-number",
"expiration_month": "cc-exp-month",
"expiration_year": "cc-exp-year",
}
class Autofill(BasePage):
"""
Page Object Model for auto site (https://mozilla.github.io/form-fill-examples/) base parent object of all
autofill page related objects
"""
# Default; subclasses will override
field_mapping = {}
fields = {}
preview_fields = {}
URL_TEMPLATE = "https://mozilla.github.io/form-fill-examples/"
def __init__(self, driver: Firefox, **kwargs):
super().__init__(driver, **kwargs)
self.driver = driver
self.browser_actions = BrowserActions(self.driver)
self.util = Utilities()
self.autofill_popup = AutofillPopup(self.driver)
def _fill_input_element(self, field_name: str, term: str):
"""
Given BrowserActions object, the string of the element to be identified and the string term to be sent to the
input, identify the webelement and send the term to the input field without any additional keystrokes.
Arguments:
field_name : The name of the input field to be identified
term: The string to be sent to the input field
"""
form_field_element = self.get_element("form-field", labels=[field_name])
if form_field_element.tag_name.lower() == "select":
selected_form = Select(form_field_element)
shortened_state = self.util.get_state_province_abbreviation(term)
selected_form.select_by_value(shortened_state)
else:
self.browser_actions.clear_and_fill(
form_field_element, term, press_enter=False
)
def scroll_to_form_field(self):
"""
Scrolls to the first available field value.
Note: First field in mapping must be the first field that is visible to be selected.
"""
if not self.field_mapping:
# Method is meant to be called by one of the classes that inherit AutoFill (CreditCardFill or AddressFill)
# Should not be called directly from an Autofill instance.
raise NotImplementedError(
"Method should only be called in inherited classes."
)
first_field = self.get_element("form-field", labels=[self.fields[0]])
self.driver.execute_script("arguments[0].scrollIntoView();", first_field)
return self
def _click_form_button(self, field_name):
"""Clicks submit on the form"""
self.element_clickable("submit-button", labels=[field_name])
self.click_on("submit-button", labels=[field_name])
def is_field_present(self, attr_name: str):
"""checks whether the attribute name exists in the mapping."""
field = self.field_mapping.get(attr_name, None)
return True if field else False
def select_first_form_field(self, autofill: bool = False):
"""
Click first available form field option.
If autofill flag is true, select autofill option for first available field.
"""
if autofill:
self.select_autofill_option(self.fields[0])
else:
self.click_form_field(self.fields[0])
return self
def click_form_field(self, attr_name: str):
"""Click the form field given the attribute name."""
field = self.field_mapping.get(attr_name, None)
if field:
self.double_click("form-field", labels=[field])
else:
logging.warning(f"The field: {attr_name} is not available in the site.")
def verify_no_dropdown_on_field_interaction(self, attr_name):
"""
Click on the form field given the attribute name and ensure that the field
does not activate the dropdown.
"""
field = self.field_mapping.get(attr_name, None)
if field:
self.double_click("form-field", labels=[field])
self.autofill_popup.ensure_autofill_dropdown_not_visible()
else:
logging.warning(f"The field: {attr_name} is not available in the site.")
def fill_and_submit(self, data_object: CreditCardBase | AutofillAddressBase | dict):
"""Fill and submit from data object or dictionary.
Arguments:
data_object: object containing autofill data.
"""
if not self.field_mapping:
# Method is meant to be called by one of the classes that inherit AutoFill (CreditCardFill or AddressFill)
# Should not be called directly from an Autofill instance.
raise NotImplementedError(
"Method should only be called in inherited classes."
)
for attr_name, field_name in self.field_mapping.items():
value = (
getattr(data_object, attr_name, None)
if not isinstance(data_object, dict)
else data_object.get(field_name)
)
if value is not None:
self._fill_input_element(field_name, value)
self._click_form_button("submit")
def update_form_data(
self,
sample_data: AutofillAddressBase | CreditCardBase,
field: str,
value: str | int,
):
"""
Update the form field with the new value.
Arguments:
sample_data: sample data instance used to verify change.
field: field being changed.
value: value being added.
"""
# updating the profile accordingly
self.update_and_save(field, value)
# autofill data
self.select_autofill_option(field)
# verifying the correct data
self.verify_form_data(sample_data)
return self
def verify_form_data(self, sample_data: CreditCardBase | AutofillAddressBase):
"""Verify that form is filled correctly against sample data."""
if not self.field_mapping:
# Method is meant to be called by one of the classes that inherit AutoFill (CreditCardFill or AddressFill)
# Should not be called directly from an Autofill instance.
raise NotImplementedError(
"Method should only be called in inherited classes."
)
is_address_fill = self.__class__ == AddressFill
non_us_ca_address = is_address_fill and sample_data.country_code not in [
"US",
"CA",
]
for attr_name, field_name in self.field_mapping.items():
if non_us_ca_address and field_name == "address-level1":
continue
if field_name != "cc-csc":
expected_value = getattr(sample_data, attr_name, None)
autofilled_field = self.get_element("form-field", labels=[field_name])
if autofilled_field.tag_name.lower() != "select":
autofilled_field_value = autofilled_field.get_attribute("value")
# self.expect_element_attribute_contains(
# "form-field", "value", expected_value, labels=[field_name]
# )
else:
autofilled_field_value = Select(
autofilled_field
).first_selected_option.text
if (
field_name == "address-level1"
and autofilled_field_value != expected_value
):
expected_value = self.util.get_state_province_abbreviation(
expected_value
)
assert expected_value in autofilled_field_value, (
f"{autofilled_field_value} is different from {expected_value}"
)
def verify_field_autofill_dropdown(
self,
fields_to_test: List[str] = None,
excluded_fields: Optional[List[str]] = None,
region: Optional[str] = None,
):
"""
A common method to check which fields trigger the autofill dropdown. This is used in both CC and Address pages.
Arguments:
fields_to_test: The primary list of fields for this page (cc fields, address fields).
excluded_fields: Fields that should NOT trigger an autofill dropdown.
region: If provided, handle region-specific behavior (e.g., skip address-level1 for DE/FR).
"""
if not self.field_mapping:
# Method is meant to be called by one of the classes that inherit AutoFill (CreditCardFill or AddressFill)
# Should not be called directly from an Autofill instance.
raise NotImplementedError(
"Method should only be called in inherited classes."
)
if fields_to_test is None:
fields_to_test = [x for x in self.field_mapping.keys() if x != "cvv"]
# Handle region-specific behavior
if region not in {"US", "CA"} and "address_level_1" in fields_to_test:
fields_to_test.remove("address_level_1")
# Check fields that SHOULD trigger the autofill dropdown
for field_name in fields_to_test:
field = self.field_mapping.get(field_name, None)
# How do i simplify this logic?
if field:
autofill_field = self.get_element("form-field", labels=[field])
if autofill_field.tag_name.lower() != "select":
# more general way of activating the dropdown
self.double_click("form-field", labels=[field])
autofill_field.send_keys("")
self.autofill_popup.ensure_autofill_dropdown_visible()
logging.info(f"Autofill dropdown appears for field '{field}'.")
else:
logging.info(
f"Field: {field_name} is a select element. No autofill option."
)
else:
logging.warning(
f"The field: {field_name} is not available in the site."
)
# Check fields that should NOT trigger the autofill dropdown
if excluded_fields:
for field_name in excluded_fields:
field = self.field_mapping.get(field_name, None)
if field:
self.double_click("form-field", labels=[field_name])
self.autofill_popup.ensure_autofill_dropdown_not_visible()
logging.info(
f"No autofill dropdown appears for field '{field_name}', as expected."
)
else:
logging.warning(
f"The field: {field_name} is not available in the site."
)
return self
def verify_field_highlight(
self,
fields_to_test: List[str] = None,
expected_highlighted_fields: Optional[List[str]] = None,
extra_fields: Optional[List[str]] = None,
region: str = None,
):
"""
A common method to check which fields have the "yellow highlight". This is used in both CC and Address pages.
Arguments:
fields_to_test: The primary list of fields for this page (cc fields, address fields).
expected_highlighted_fields: Which ones are expected to be highlighted. Defaults to all in `fields_to_test`.
extra_fields: If some pages have extra fields to test (e.g. 'cc-csc'), pass them here.
region: region to test
"""
if not self.field_mapping:
# Method is meant to be called by one of the classes that inherit AutoFill (CreditCardFill or AddressFill)
# Should not be called directly from an Autofill instance.
raise NotImplementedError(
"Method should only be called in inherited classes."
)
if fields_to_test is None:
fields_to_test = set(self.field_mapping.keys())
if region not in {"US", "CA"} and "address_level_1" in fields_to_test:
fields_to_test.remove("address_level_1")
if expected_highlighted_fields is None:
# By default, everything in fields_to_test is expected to be highlighted except cvv for cc
expected_highlighted_fields = fields_to_test
if "cvv" in expected_highlighted_fields:
expected_highlighted_fields.remove("cvv")
if extra_fields:
fields_to_actually_check = fields_to_test + extra_fields
else:
fields_to_actually_check = fields_to_test
def is_yellow_highlight(rgb_tuple):
"""
Returns True if the color tuple is bright yellow-ish.
"""
if len(rgb_tuple) == 3:
r, g, b = rgb_tuple
else:
r, g, b, *_ = rgb_tuple
return (r >= 250) and (g >= 250) and (180 < b < 220)
for field_name in fields_to_actually_check:
field = self.field_mapping.get(field_name, None)
if field:
autofill_field = self.get_element("form-field", labels=[field])
if autofill_field.tag_name.lower() != "select":
# Focus the field so the highlight is visible
self.click_on("form-field", labels=[field])
# Get all colors in the field
selector = self.get_selector("form-field", labels=[field])
colors = self.browser_actions.get_all_colors_in_element(selector)
logging.info(f"Colors found in '{field}': {colors}")
# Check the highlight
is_field_highlighted = any(
is_yellow_highlight(color) for color in colors
)
should_be_highlighted = field_name in expected_highlighted_fields
# Assert based on expectation
if should_be_highlighted:
assert is_field_highlighted, (
f"Expected yellow highlight on '{field}', but none found."
)
logging.info(f"Yellow highlight found in '{field}'.")
else:
assert not is_field_highlighted, (
f"Expected NO yellow highlight on '{field}', but found one."
)
logging.info(f"No yellow highlight in '{field}', as expected.")
else:
logging.info(
f"Field: {field} is a select element. No autofill option."
)
else:
logging.warning(f"The field: {field} is not available in the site.")
return self
def verify_all_fields_cleared(self):
"""
Verifies that all autofill fields are empty.
"""
for field_name in self.fields:
field_element = self.get_element("form-field", labels=[field_name])
value = field_element.get_attribute("value")
if field_element.tag_name.lower() != "select":
assert not value, f"Field '{field_name}' is not empty: Found '{value}'"
@BasePage.context_chrome
def verify_autofill_data_on_hover(
self, autofill_data: CreditCardBase | AutofillAddressBase
):
"""
Verifies that the autofill preview data matches the expected values when hovering
Arguments:
autofill_data: CreditCardBase | AutofillAddressBase object containing expected fake data.
"""
# Get preview data from hovering through the chrome context
try:
# Attempt to parse the string as JSON
container = json.loads(
self.autofill_popup.get_element("preview-form-container").get_attribute(
"ac-comment"
)
)
except json.JSONDecodeError:
# If parsing fails, raise ValueError.
raise ValueError("Given preview data is incomplete.")
container_data = container.get("fillMessageData", {}).get("profile", {})
assert container_data, "No preview data available."
assert all(field in container_data.keys() for field in self.preview_fields), (
f"Not all fields present in preview data."
)
# sanitize data
if autofill_data.__class__ == CreditCardBase:
autofill_data.card_number = autofill_data.card_number[-4:]
else:
if autofill_data.country_code in {"US", "CA"} and (
len(container_data["address-level1"]) == 2
and len(autofill_data.address_level_1) > 2
):
autofill_data.address_level_1 = (
self.util.get_state_province_abbreviation(
autofill_data.address_level_1
)
)
for field, value in container_data.items():
if field in self.preview_fields:
value = self.sanitize_preview_data(field, str(value))
# Check if this value exists in our CreditCardBase | AutofillAddressBase object
is_present = any(
[value in val for val in autofill_data.__dict__.values()]
)
assert is_present, (
f"Mismatched data: {(field, value)} not in {autofill_data.__dict__.values()}."
)
def sanitize_preview_data(self, field, value):
if field == "cc-number":
value = value[-4:]
elif field == "cc-exp-year":
value = value[-2:]
elif value[0] == "+":
value = self.util.normalize_phone_number(value)
return value
def select_autofill_option(self, field, index: int = 1):
"""
Presses the autofill panel that pops up after you double-click an input field
Argument:
autofill_popup: AutofillPopup
field: field to click to show autofill option.
index: which autofill option to pick.
"""
autofill_field = self.get_element("form-field", labels=[field])
if autofill_field.tag_name.lower() != "select":
self.double_click("form-field", labels=[field])
self.autofill_popup.ensure_autofill_dropdown_visible()
self.autofill_popup.select_nth_element(index)
else:
logging.info(f"Field: {field} is a select element. No autofill option.")
return self
def fill_and_save(
self, region: str = "US", door_hanger: bool = True
) -> AutofillAddressBase | CreditCardBase:
"""
Fills form with randomly generated data and interacts with the autofill popup.
Arguments:
region: Country code in use
door_hanger: check to click save on door hanger
"""
autofill_data = (
self.util.fake_autofill_data(region)
if self.__class__ == AddressFill
else self.util.fake_credit_card_data(region)
)
self.fill_and_submit(autofill_data)
if door_hanger:
self.autofill_popup.click_doorhanger_button("save")
return autofill_data
def update_and_save(self, field: str, value: str | int, door_hanger: bool = True):
"""
Update form with new field value and save.
Arguments:
field: field label.
value: new value to be updated.
door_hanger: bool to indication interaction with door_hanger.
"""
self._fill_input_element(field, value)
self._click_form_button("submit")
if door_hanger:
if field == "cc-number":
self.autofill_popup.click_doorhanger_button("save")
else:
self.autofill_popup.click_doorhanger_button("update")
def check_autofill_preview_for_field(
self,
field_label: str,
sample_data: CreditCardBase | AutofillAddressBase,
region: str = None,
):
"""
Check that hovering over a field autofill option will prefill the other fields.
Arguments:
field_label: field name.
sample_data: autofill sample data.
region: region being tested.
"""
if (
self.__class__ == AddressFill
and field_label == "address_level_1"
and region not in {"US", "CA"}
):
return
field = self.field_mapping.get(field_label, None)
if field:
autofill_field = self.get_element("form-field", labels=[field])
if autofill_field.tag_name.lower() != "select":
self.double_click("form-field", labels=[field])
self.autofill_popup.ensure_autofill_dropdown_visible()
self.autofill_popup.hover("select-form-option")
self.verify_autofill_data_on_hover(sample_data)
self.click_on("form-field", labels=[field])
else:
logging.info(
f"Field: {field_label} is a select element. No autofill option."
)
else:
logging.info(f"The field: {field_label} is not present in the site.")
def clear_and_verify_all_fields(
self,
sample_data: AutofillAddressBase | CreditCardBase = None,
region: str = None,
):
"""
Autofill all form fields and verifies that they are empty.
Arguments:
region: region being tested
sample_data: verify autofill against sample data if present
"""
fields = [x for x in self.field_mapping.keys() if x != "cvv"]
for field in fields:
self.clear_and_verify(field, sample_data, region)
def clear_and_verify(
self,
field_label: str,
sample_data: CreditCardBase | AutofillAddressBase = None,
region: str = None,
):
"""
Autofills a form field, clears it, and verifies that it is empty.
If sample data is present, will verify that data is filled correctly.
Arguments:
field_label : The label of the field being autofilled.
region : region being tested
sample_data: sample data for cc or address form.
"""
# Skip address-level1 (State) selection for DE and FR
if (
region not in {"US", "CA"}
and self.__class__ == AddressFill
and field_label == "address_level_1"
):
return
field = self.field_mapping.get(field_label, None)
if field:
autofill_field = self.get_element("form-field", labels=[field])
if autofill_field.tag_name.lower() != "select":
# Double-click a field and choose the first element from the autocomplete dropdown
self.double_click("form-field", labels=[field])
self.autofill_popup.ensure_autofill_dropdown_visible()
self.autofill_popup.select_nth_element(1)
if sample_data:
## verify data
self.verify_form_data(sample_data)
# Clear form autofill
self.double_click("form-field", labels=[field])
self.autofill_popup.ensure_autofill_dropdown_visible()
self.autofill_popup.click_clear_form_option()
# Verify all fields are cleared
self.verify_all_fields_cleared()
else:
logging.info(
f"Field: {field_label} is a select element. No autofill option."
)
else:
logging.warning(f"The field: {field_label} is not available in the site.")
def generate_field_data(
self, sample_data: AutofillAddressBase | CreditCardBase, field: str, region: str
) -> str | int:
"""
Generates a new data for sample data according to field given, updates the information in the form.
Arguments:
sample_data: sample data instance being updated
field: field being updated
region: region being tested
"""
faker_method = (
self.util.fake_credit_card_data
if self.__class__ == CreditCardFill
else self.util.fake_autofill_data
)
inverted_mapping = {v: k for k, v in self.field_mapping.items()}
new_sample_data_value = getattr(
faker_method(country_code=region), inverted_mapping[field]
)
while new_sample_data_value == getattr(sample_data, inverted_mapping[field]):
new_sample_data_value = getattr(
faker_method(country_code=region), inverted_mapping[field]
)
setattr(sample_data, inverted_mapping[field], new_sample_data_value)
return new_sample_data_value
class AddressFill(Autofill):
"""
Page Object Model for address autofill site.
base site is: (https://mozilla.github.io/form-fill-examples/basic.html)
"""
def __init__(
self,
driver: Firefox,
url_template=None,
field_mapping=None,
fields=None,
**kwargs,
):
super().__init__(driver, **kwargs)
self.field_mapping = (
field_mapping if field_mapping else base_address_field_mapping
)
self.fields = fields if fields else base_address_fields
self.preview_fields = set(
map(
lambda field: base_preview_address_mapping.get(field, field),
self.field_mapping.keys(),
)
)
self.URL_TEMPLATE = url_template if url_template else BASE_ADDRESS_URL_TEMPLATE
class CreditCardFill(Autofill):
"""
Page Object Model for cc autofill site
base site is: (https://mozilla.github.io/form-fill-examples/basic_cc.html)
"""
def __init__(
self,
driver: Firefox,
url_template=None,
field_mapping=None,
fields=None,
**kwargs,
):
super().__init__(driver, **kwargs)
self.field_mapping = field_mapping if field_mapping else base_cc_field_mapping
self.fields = fields if fields else base_cc_fields
self.preview_fields = set(
map(
lambda field: base_preview_cc_mapping.get(field),
self.field_mapping.keys(),
)
)
self.preview_fields = {
field for field in self.preview_fields if field is not None
}
self.URL_TEMPLATE = url_template if url_template else BASE_CC_URL_TEMPLATE
class LoginAutofill(Autofill):
"""
Page Object Model for the form autofill demo page with many logins
"""
URL_TEMPLATE = "https://mozilla.github.io/form-fill-examples/password_manager/login_and_pw_change_forms.html"
class LoginForm:
"""
Subclass of the Login Autofill Form where you can interact with the Login Form
"""
def __init__(self, parent: "LoginAutofill") -> None:
self.parent = parent
self.username_field = None
self.password_field = None
self.submit_button = None
def fill_username(self, username: str) -> None:
"""Fill the username field after ensuring it's clickable."""
self.parent.element_clickable("username-login-field")
if self.username_field is None:
self.username_field = self.parent.get_element("username-login-field")
self.username_field.send_keys(username)
def fill_password(self, password: str) -> None:
"""Fill the password field after ensuring it's clickable."""
self.parent.element_clickable("password-login-field")
if self.password_field is None:
self.password_field = self.parent.get_element("password-login-field")
self.password_field.send_keys(password)
def submit(self) -> None:
if self.submit_button is None:
self.submit_button = self.parent.get_element("submit-button-login")
self.submit_button.click()
class TextAreaFormAutofill(Autofill):
"""
Page Object Model for the form autofill demo page with a textarea
"""
URL_TEMPLATE = "https://mozilla.github.io/form-fill-examples/textarea_select.html"