diff --git a/.drone.yml b/.drone.yml index e8dd3479..eb40e21a 100644 --- a/.drone.yml +++ b/.drone.yml @@ -11,7 +11,7 @@ docker-compose: &docker-compose test: &test <<: *when-pr-push image: fidals/se:dev - secrets: [ FTP_IP, FTP_USER, FTP_PASS, SELENIUM_WAIT_SECONDS, SELENIUM_TIMEOUT_SECONDS ] + secrets: [ FTP_IP, FTP_USER, FTP_PASS, SELENIUM_WAIT_SECONDS, SELENIUM_IMPLICIT_WAIT, SELENIUM_TIMEOUT_SECONDS ] environment: - TEST_ENV=true - DJANGO_SETTINGS_MODULE=shopelectro.settings.drone @@ -29,6 +29,7 @@ test: &test - FTP_PASS=${FTP_PASS} - SELENIUM_WAIT_SECONDS=${SELENIUM_WAIT_SECONDS} - SELENIUM_TIMEOUT_SECONDS=${SELENIUM_TIMEOUT_SECONDS} + - SELENIUM_IMPLICIT_WAIT=${SELENIUM_IMPLICIT_WAIT} lint: &lint <<: *when-pr-push diff --git a/docker/drone_env/app b/docker/drone_env/app index 89305083..4d096db7 100644 --- a/docker/drone_env/app +++ b/docker/drone_env/app @@ -19,3 +19,4 @@ RABBITMQ_PORT=5672 SELENIUM_WAIT_SECONDS=120 SELENIUM_TIMEOUT_SECONDS=60 +SELENIUM_IMPLICIT_WAIT=15 diff --git a/docker/env_files/app.dist b/docker/env_files/app.dist index 7cb27a0a..fca7d857 100644 --- a/docker/env_files/app.dist +++ b/docker/env_files/app.dist @@ -19,3 +19,4 @@ RABBITMQ_PORT=5672 SELENIUM_WAIT_SECONDS=60 SELENIUM_TIMEOUT_SECONDS=30 +SELENIUM_IMPLICIT_WAIT=15 diff --git a/shopelectro/selenium/driver.py b/shopelectro/selenium/driver.py index b95e39ee..10fc91d9 100644 --- a/shopelectro/selenium/driver.py +++ b/shopelectro/selenium/driver.py @@ -16,6 +16,7 @@ def __init__(self, *, site_url, **kwargs): self.site_url = site_url self.wait = WebDriverWait(self, settings.SELENIUM_WAIT_SECONDS) + self.short_wait = WebDriverWait(self, settings.SELENIUM_WAIT_SECONDS // 4) def __enter__(self): return self diff --git a/shopelectro/selenium/elements/cart.py b/shopelectro/selenium/elements/cart.py index 91af6323..15aea396 100644 --- a/shopelectro/selenium/elements/cart.py +++ b/shopelectro/selenium/elements/cart.py @@ -1,3 +1,6 @@ +from contextlib import contextmanager + +from selenium.common.exceptions import TimeoutException from selenium.webdriver.common.by import By from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.support import expected_conditions as EC @@ -22,20 +25,36 @@ def _hover(self): (By.CLASS_NAME, 'js-cart-wrapper') )) + # @todo #920:15m Document the Cart.wait_changes. + # Cover corner cases with TimeoutException. + + @contextmanager + def wait_changes(self): + def wait_changes(browser): + try: + return positions_before != self.positions() + except TimeoutException: + return False + + positions_before = self.positions() + yield + self.driver.wait.until(wait_changes) + def positions(self) -> [elements.CartPosition]: - positions_count = len(self.driver.find_elements( - By.CLASS_NAME, 'basket-item' - )) + try: + # use short_wait to avoid long pauses in case of the empty cart + positions_count = len(self.driver.short_wait.until(EC.presence_of_all_elements_located( + (By.CLASS_NAME, 'basket-item') + ))) + except TimeoutException: + positions_count = 0 + return [elements.CartPosition(self.driver, i) for i in range(positions_count)] def remove(self, position: elements.CartPosition): - def wait_removing(browser): - return len(Cart(browser).positions()) < old_count - - old_count = len(self.positions()) - self._hover() - position.remove_from_cart() - self.driver.wait.until(wait_removing) + with self.wait_changes(): + self._hover() + position.remove_from_cart() def clear(self): self._hover() diff --git a/shopelectro/selenium/elements/product.py b/shopelectro/selenium/elements/product.py index ee7a38f6..d2fe9317 100644 --- a/shopelectro/selenium/elements/product.py +++ b/shopelectro/selenium/elements/product.py @@ -1,16 +1,20 @@ import abc +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC + from shopelectro.selenium.elements import Button, Unavailable from shopelectro.selenium.driver import SiteDriver -from selenium.webdriver.common.by import By - class Product(abc.ABC): def name(self): raise Unavailable('determine the product name.') + def vendor_code(self): + raise Unavailable('determine the vendor code.') + def price(self): raise Unavailable('determine the product price.') @@ -26,17 +30,57 @@ def remove_from_cart(self): class CatalogCard(Product): - def __init__(self, driver: SiteDriver, card_index: int): + def __init__( + self, + driver: SiteDriver, + *, + _index: int = None, + _id: int = None, + ): """ Ctor. - :param int card_index: The index number of the product card at a category page + :param int _index: The index number of the product card at a category page """ self.driver = driver - self.xpath = f'//*[@id="products-wrapper"]/div[{card_index}]/div[2]/div[5]/' + + if (_index is None and not _id) or (_index and _id): + raise ValueError('Provide either _index or _id to work with card.') + self._id = _id + self._index = _index + + @classmethod + def with_id( + cls, + driver: SiteDriver, + id_: int, + ): + return cls(driver, _id=id_) + + @classmethod + def with_index( + cls, + driver: SiteDriver, + index: int, + ): + return cls(driver, _index=index) + + def _build_xpath(self, path=''): + product_xpath = '//*[@id="products-wrapper"]' + + if self._id: + return f'{product_xpath}//*[@data-product-id="{self._id}"]/{path}' + + # xpath indexes starts from 1 + return f'{product_xpath}/div[{self._index + 1}]/{path}' + + def vendor_code(self): + return self.driver.wait.until(EC.visibility_of_element_located( + (By.XPATH, self._build_xpath('div[2]/div[1]')) + )).text.split(' ')[1] def add_to_cart(self): - Button(self.driver, (By.XPATH, f'{self.xpath}button')).click() + Button(self.driver, (By.XPATH, self._build_xpath('div[2]/div[5]/button'))).click() class ProductCard(Product): @@ -50,10 +94,27 @@ def add_to_cart(self): class CartPosition(Product): - def __init__(self, driver: SiteDriver, pos_index: int): + def __init__(self, driver: SiteDriver, index: int): self.driver = driver # xpath indexes starts from 1 - self.xpath = f'//ul[@id="basket-list"]/li[{pos_index + 1}]/' + self.xpath = f'//ul[@id="basket-list"]/li[{index + 1}]/' + + def __hash__(self): + el = self._data_element() + return hash( + el.get_attribute('data-product-id') + + '/' + + el.get_attribute('data-product-count') + ) + + def __eq__(self, other: 'CartPosition'): + return hash(self) == hash(other) + + def _data_element(self): + # use short_wait, because a position could be stale + return self.driver.short_wait.until(EC.presence_of_element_located( + (By.XPATH, f'{self.xpath}i') + )) def name(self): raise Unavailable('determine the position name.') @@ -62,7 +123,7 @@ def price(self): raise Unavailable('determine the position price.') def quantity(self): - raise Unavailable('determine the position quantity.') + return self._data_element().get_attribute('data-product-count') def remove_from_cart(self): Button(self.driver, (By.XPATH, f'{self.xpath}i')).click() diff --git a/shopelectro/selenium/pages/category.py b/shopelectro/selenium/pages/category.py index d29243ac..ceb41e3c 100644 --- a/shopelectro/selenium/pages/category.py +++ b/shopelectro/selenium/pages/category.py @@ -1,9 +1,11 @@ import typing -from shopelectro.selenium.elements import CatalogCard -from shopelectro.selenium.pages import Page - from django.urls import reverse +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC + +from shopelectro.selenium import elements +from shopelectro.selenium.pages import Page # @todo #682:120m Implement and reuse shopelectro.selenium.CategoryPage for selenium tests. @@ -18,11 +20,37 @@ def __init__(self, driver, slug): def path(self): return reverse('category', args=(self.slug,)) - def product_cards(self) -> typing.List[CatalogCard]: - raise NotImplementedError + def product_cards(self) -> typing.List[elements.CatalogCard]: + products_count = len(self.driver.find_elements( + By.CLASS_NAME, 'product-card' + )) + return [elements.CatalogCard.with_index(self.driver, i) for i in range(products_count)] + + def find_card(self, id_: int) -> elements.CatalogCard: + return elements.CatalogCard.with_id(self.driver, id_) - def add_to_cart(self, products: typing.List[CatalogCard] = None): - default = [CatalogCard(self.driver, i) for i in range(1, 7)] + def load_more(self): + old_len = len(self.product_cards()) + locator = (By.ID, 'btn-load-products') + + if not self.driver.wait.until(EC.presence_of_element_located( + locator + )).is_displayed(): + raise elements.Unavailable('load more') + + elements.Button(self.driver, locator).click() + + self.wait.until_not( + EC.text_to_be_present_in_element( + (By.CLASS_NAME, 'js-products-showed-count'), + str(old_len), + ) + ) + + def add_to_cart(self, products: typing.List[elements.CatalogCard] = None): + default = [elements.CatalogCard.with_index(self.driver, i) for i in range(6)] products = products or default - for product in products: - product.add_to_cart() + + with self.cart().wait_changes(): + for product in products: + product.add_to_cart() diff --git a/shopelectro/selenium/pages/product.py b/shopelectro/selenium/pages/product.py index 66b5d15e..097ab372 100644 --- a/shopelectro/selenium/pages/product.py +++ b/shopelectro/selenium/pages/product.py @@ -18,11 +18,5 @@ def path(self): return reverse('product', args=(self.vendor_code,)) def add_to_cart(self): - def wait_adding(browser): - # @todo #808:60m Create a context manager for cart-related tests. - # It should wait position changes after completed inner block. - return len(elements.Cart(browser).positions()) > old_count - - old_count = len(self.cart().positions()) - elements.ProductCard(self.driver).add_to_cart() - self.driver.wait.until(wait_adding) + with self.cart().wait_changes(): + elements.ProductCard(self.driver).add_to_cart() diff --git a/shopelectro/settings/drone.py b/shopelectro/settings/drone.py index 045e9007..50194a9f 100644 --- a/shopelectro/settings/drone.py +++ b/shopelectro/settings/drone.py @@ -15,3 +15,4 @@ SELENIUM_URL = os.environ.get('SELENIUM_URL', 'http://selenium:4444/wd/hub') SELENIUM_WAIT_SECONDS = int(os.environ['SELENIUM_WAIT_SECONDS']) SELENIUM_TIMEOUT_SECONDS = int(os.environ['SELENIUM_TIMEOUT_SECONDS']) +SELENIUM_IMPLICIT_WAIT = int(os.environ['SELENIUM_IMPLICIT_WAIT']) diff --git a/shopelectro/tests/helpers.py b/shopelectro/tests/helpers.py index b3e77778..6855ed3a 100644 --- a/shopelectro/tests/helpers.py +++ b/shopelectro/tests/helpers.py @@ -78,9 +78,7 @@ def setUpClass(cls): """Instantiate browser instance.""" super().setUpClass() cls.browser = SiteDriver(site_url=cls.live_server_url) - # @todo #371:15m Move selenium timeout to env var. stb2 - # To be able to change it from drone without touching code. - cls.browser.implicitly_wait(30) + cls.browser.implicitly_wait(settings.SELENIUM_IMPLICIT_WAIT) cls.browser.set_page_load_timeout(settings.SELENIUM_TIMEOUT_SECONDS) # Fresh created browser failures on maximizing window. # This bug is won't fixed by selenium guys https://goo.gl/6Ttguf diff --git a/shopelectro/tests/tests_js_analytics.py b/shopelectro/tests/tests_js_analytics.py index c3a7cc99..215343c2 100644 --- a/shopelectro/tests/tests_js_analytics.py +++ b/shopelectro/tests/tests_js_analytics.py @@ -87,10 +87,8 @@ class YandexEcommerce(Ecommerce): fixtures = ['dump.json'] - # @todo #808:120m Test Yandex ecommerce goals. - # Here are goals left to test: - # - onProductAdd from catalog and order pages - # - onProductRemove from order page + # @todo #820:120m Test Yandex ecommerce add and remove goals from the order page. + # Get rid of code duplications. def tearDown(self): # delete the session to clear the cart @@ -229,7 +227,38 @@ def test_add_from_product_page(self): reached_goals = self.get_goals() self.assertTrue(reached_goals) - reached = self.get_goal(reached_goals, 1) + reached = self.get_goal(reached_goals, 1) # Ignore CPDBear + self.assertIn('add', reached) + self.assertEqual(reached['currencyCode'], 'RUB') + + reached_detail = reached['add'] + self.assertEqual( + len(reached_detail['products']), + 1, + ) + + self.assertEqual( + reached_detail['products'][0], + { + 'id': product.id, + 'name': product.name, + 'brand': product.get_brand_name(), + 'quantity': 1, + 'category': product.category.name, + } + ) + + def test_add_from_category_page(self): + product = Product.objects.first() + page = selenium.CategoryPage(self.browser, product.category.page.slug) + page.load() + card = page.find_card(product.id) + page.add_to_cart([card]) + + reached_goals = self.get_goals() + self.assertTrue(reached_goals) + + reached = self.get_goal(reached_goals) self.assertIn('add', reached) self.assertEqual(reached['currencyCode'], 'RUB') diff --git a/shopelectro/tests/tests_selenium_mobile.py b/shopelectro/tests/tests_selenium_mobile.py index 9702b657..0ad6c250 100644 --- a/shopelectro/tests/tests_selenium_mobile.py +++ b/shopelectro/tests/tests_selenium_mobile.py @@ -38,7 +38,7 @@ def setUpClass(cls): site_url=cls.live_server_url, desired_capabilities=chrome_options.to_capabilities(), ) - cls.browser.implicitly_wait(10) + cls.browser.implicitly_wait(settings.SELENIUM_IMPLICIT_WAIT) @property def wait(self):