diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac13aa0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.vscode +__pycache__ +.pytest_cache +env +local.log diff --git a/README.md b/README.md index 60b58a2..0bf08b4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,137 @@ -# BrowserStack Smart Test Selection AI Agent +![BrowserStack Logo](https://d98b8t1nnulk5.cloudfront.net/production/images/layout/logo-header.png?1469004780) + +# Smart Test Selection & Orchestration AI Agent Demo for BrowserStack Automate + +## Table of Contents +- [What is Smart Test Selection?](#what-is-smart-test-selection) + +- [How Smart Test Selection Agent Works](#how-smart-test-selection-agent-works) + +- [Steps to run the demo](#steps-to-run-the-demo) + --- + ## What is Smart Test Selection? -BrowserStack's Smart Test Selection Agent uses AI to identify and run only the tests impacted (or likely to fail) by your code changes, reducing build time and cost by up to 50%. This demo uses the BrowserStack Java SDK with TestNG. +BrowserStack's Smart Test Selection Agent uses AI to identify and run only the tests impacted by your code changes, reducing build time and cost. This demo uses the BrowserStack Python SDK with PyTest: +- App Repo – [test-selection-demo-app-browserstack](https://github.com/browserstack/test-selection-demo-app-browserstack) +- Test Repo – [test-selection-demo-test-browserstack](https://github.com/browserstack/test-selection-demo-test-browserstack) + +--- + +## How Smart Test Selection Agent Works +- Agent does a static analysis of the test repo to understand the tests structure, workflows being tested etc. Based on the code changes and static analysis done earlier, it then predicts impacted tests for incoming builds. +- Agent needs read-only access to the application repo and test repo - this can be given via BrowserStack Github app + +For detailed integration steps follow the [Test Selection Documentation](https://www.browserstack.com/docs/automate/selenium/smart-test-selection). + +## Steps to run the demo +- This demo showcases BrowserStack's Impact Analysis agent using the **Github App** integration approach. +- First, we run a build with Impact Analysis disabled and then run the same build with it enabled, allowing you to clearly see the impact - with Impact Analysis, only the tests impacted by the code change run, resulting in a reduction in execution time. + +### Prerequisites +- Python 3.9+ +- BrowserStack Account with AI Enabled: [Activate BrowserStack AI preferences](https://www.browserstack.com/docs/iaam/settings-and-permissions/activate-browserstack-ai) + +### Step 1: Setup Test Repo + +``` +# Clone the test repo +git clone https://github.com/browserstack/test-selection-demo-test-browserstack.git +cd test-selection-demo-test-browserstack + +> If your org is not `browserstack`, fork the test and app repos into your own org first, and clone the forked test repo instead. + +# Checkout the pytest demo branch +git checkout pytest-automate + +# Create and activate a virtual environment +python3 -m venv env +source env/bin/activate # on Mac +env\Scripts\activate # on Windows + +# Install dependencies +pip install -r requirements.txt +``` + +### Step 2: Add BrowserStack Credentials +- Update `username` and `accessKey` in the `browserstack.yml` file with your BrowserStack access credentials found [here](https://www.browserstack.com/accounts/profile/details) +``` +userName: +accessKey: +``` + +### Step 3: Connect your repos for Impact Analysis +- Provide read-only access to the demo test and app repos via [BrowserStack GitHub Actions](https://automate.browserstack.com/integrations?category=cicd) in Integrations (user with BrowserStack Admin + Github Admin access can configure the repos) +- In the AI Agents Configuration page under Settings in BrowserStack, add the details for the test repo (e.g. `pytest-automate` branch, `tests` folder) along with the app repo. +A sample configuration is shown in the image below +![Test Selection Repo Configuration](./test-selection-repo-config.png) + +- Update the app repo reference used for impact mapping: + Create a test-selection.json file in the demo test repo. Specify the feature branch of the app repo (which has the code changes). A sample is shown below +```json +{ + "DEMO_APP": { + "url": "https://github.com//test-selection-demo-app-browserstack", + "featureBranch": "demo-temp" + } +} +``` + Replace `` with your own GitHub org (not `browserstack`). +- Once configured, trigger static analysis of the test repo by clicking on 'Analyse Test Repo' in AI Agent Configuration settings — this is required before running a build with Impact Analysis enabled, and takes 5-10 minutes. + +### Step 4: Run a build without Impact Analysis enabled +- In `browserstack.yml`, add projectName, buildName and disable Test Selection +```yaml +projectName: Smart Test Selection Impact Analysis Demo Project +buildName: Smart Test Selection Impact Analysis Demo Build + +testOrchestrationOptions: + runSmartSelection: + enabled: false + mode: 'relevantOnly' +``` + +- Run the build with: +``` +browserstack-sdk pytest tests/ +``` +- Results: + - All tests in the suite run + - This is our baseline - the full suite always runs regardless of which parts of the code changed + +### Step 5: Run a build with Impact Analysis enabled +- In `browserstack.yml`, enable Test Selection +```yaml +projectName: Smart Test Selection Impact Analysis Demo Project +buildName: Smart Test Selection Impact Analysis Demo Build + +testOrchestrationOptions: + runSmartSelection: + enabled: enable + mode: 'relevantOnly' +``` + +- Run the build with: +``` +browserstack-sdk pytest tests/ +``` +- Results: + - Impact Analysis analyzes the code changes in the demo app + - It determines which tests are impacted by those changes + - Instead of running the full suite, the agent selects and runs only the impacted tests + - All other unimpacted tests are automatically skipped/dropped +--- + +### Step 6: View Test Selection Report +- Once the build with Impact Analysis enabled completes, open the build on the BrowserStack Automate dashboard and go to the **Test Selection Report** in the AI Agents Report tab. +- The report shows: + - The list of tests selected to run for this build + - The reasoning behind why the agent selected each test + - The tests that were skipped, since they weren't impacted by the code change + +![Test Selection Report](./test-selection-report.png) + +--- -## Introduction -This repository demonstrates BrowserStack's Smart Selection AI Agent. For demo steps, checkout branch [`tesng-automate`](https://github.com/browserstack/test-selection-demo-test-browserstack/tree/testng-automate) and follow the readme +## Notes +* You can view your test results on the [BrowserStack Automate dashboard](https://www.browserstack.com/automate) diff --git a/browserstack.yml b/browserstack.yml new file mode 100644 index 0000000..3592b5b --- /dev/null +++ b/browserstack.yml @@ -0,0 +1,26 @@ +userName: username +accessKey: password + +projectName: Smart Test Selection Impact Analysis Demo Project +buildName: Smart Test Selection Impact Analysis Demo Build +buildIdentifier: '#${BUILD_NUMBER}' +framework: pytest + +testObservability: true +automation: false +browserstackLocal: false +platforms: + - os: Windows + osVersion: 11 + browserName: Chrome + browserVersion: latest + +source: pytest-browserstack:local-sdk:v1.0 +debug: true +logLevel: debug + +testOrchestrationOptions: + runSmartSelection: + enabled: true + mode: 'relevantFirst' + source: 'test-selection.json' diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..257906d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +browserstack-local +selenium>=3.14 +pytest==7.4.4 +pytest-selenium +pyobjc-core<12 ; sys_platform == "darwin" +pyobjc-framework-Cocoa<12 ; sys_platform == "darwin" +pyobjc-framework-SystemConfiguration<12 ; sys_platform == "darwin" +browserstack-sdk @ https://sdk-assets.browserstack.com/python/browserstack_sdk-latest.tar.gz \ No newline at end of file diff --git a/test-selection-repo-config.png b/test-selection-repo-config.png new file mode 100644 index 0000000..0a29ee7 Binary files /dev/null and b/test-selection-repo-config.png differ diff --git a/test-selection-report.png b/test-selection-report.png new file mode 100644 index 0000000..5d474ed Binary files /dev/null and b/test-selection-report.png differ diff --git a/test-selection.json b/test-selection.json new file mode 100644 index 0000000..55a89f6 --- /dev/null +++ b/test-selection.json @@ -0,0 +1,6 @@ +{ + "DEMO_APP": { + "url": "https://github.com/browserstack/test-selection-demo-app-browserstack", + "featureBranch": "demo-temp" + } +} diff --git a/tests/auth/__init__.py b/tests/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/auth/test_login_invalid_credentials.py b/tests/auth/test_login_invalid_credentials.py new file mode 100644 index 0000000..7554060 --- /dev/null +++ b/tests/auth/test_login_invalid_credentials.py @@ -0,0 +1,17 @@ +import time + +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC + + +def test_login_fails_with_invalid_credentials(demo): + demo.open_login_page() + demo.driver.find_element(By.ID, "email-input").send_keys("fake@example.com") + demo.driver.find_element(By.ID, "password-input").send_keys("wrong-password") + time.sleep(1) + demo.driver.find_element(By.ID, "login-submit").click() + error_banner = demo.wait_for(5).until( + EC.visibility_of_element_located((By.ID, "login-error"))) + assert "Login failed" in error_banner.text, \ + "Inline login error banner should mention login failure" + assert "/login" in demo.driver.current_url diff --git a/tests/auth/test_login_password_toggle.py b/tests/auth/test_login_password_toggle.py new file mode 100644 index 0000000..2c7fada --- /dev/null +++ b/tests/auth/test_login_password_toggle.py @@ -0,0 +1,9 @@ +from selenium.webdriver.common.by import By + + +def test_show_password_toggle_changes_input_type(demo): + demo.open_login_page() + password_field = demo.driver.find_element(By.ID, "password-input") + assert password_field.get_attribute("type") == "password" + demo.driver.find_element(By.ID, "show-password").click() + assert password_field.get_attribute("type") == "text" diff --git a/tests/auth/test_login_quick_select.py b/tests/auth/test_login_quick_select.py new file mode 100644 index 0000000..3f0f2a6 --- /dev/null +++ b/tests/auth/test_login_quick_select.py @@ -0,0 +1,13 @@ +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC + + +def test_quick_select_user_can_login(demo): + email = demo.first_demo_user_email() + demo.login_using_quick_select(email) + demo.open_profile_page() + demo.wait_for(10).until(EC.visibility_of_element_located( + (By.XPATH, "//h2[contains(@class,'text-xl')]"))) + profile_email = demo.driver.find_element( + By.XPATH, "//div[contains(@class,'text-gray-600')][1]") + assert email in profile_email.text diff --git a/tests/auth/test_login_redirect_when_authenticated.py b/tests/auth/test_login_redirect_when_authenticated.py new file mode 100644 index 0000000..3b9a500 --- /dev/null +++ b/tests/auth/test_login_redirect_when_authenticated.py @@ -0,0 +1,8 @@ +from selenium.webdriver.support import expected_conditions as EC + + +def test_visiting_login_while_authenticated_redirects_to_profile(demo): + demo.login_with_random_demo_user() + demo.open_login_page() + demo.wait_for(5).until(EC.url_contains("/profile")) + assert demo.driver.current_url.endswith("/profile") diff --git a/tests/auth/test_logout_clears_session.py b/tests/auth/test_logout_clears_session.py new file mode 100644 index 0000000..2fb8769 --- /dev/null +++ b/tests/auth/test_logout_clears_session.py @@ -0,0 +1,12 @@ +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC + + +def test_logout_from_profile_clears_session(demo): + demo.login_with_random_demo_user() + demo.open_profile_page() + demo.wait_for(5).until(EC.visibility_of_element_located( + (By.XPATH, "//button[contains(text(),'Logout')]"))).click() + login_header = demo.wait_for(5).until(EC.visibility_of_element_located( + (By.XPATH, "//h1[contains(text(),'Login') or contains(text(),'Welcome Back')]"))) + assert "Login" in login_header.text or "Welcome" in login_header.text diff --git a/tests/bstack-local-test.py b/tests/bstack-local-test.py new file mode 100644 index 0000000..2e76538 --- /dev/null +++ b/tests/bstack-local-test.py @@ -0,0 +1,6 @@ +import pytest + +def test_local(selenium): + selenium.get('http://bs-local.com:45454') + local_page_title = selenium.title + assert local_page_title == 'BrowserStack Local' diff --git a/tests/bstack-sample-test.py b/tests/bstack-sample-test.py new file mode 100644 index 0000000..3a21d93 --- /dev/null +++ b/tests/bstack-sample-test.py @@ -0,0 +1,20 @@ +import pytest +from selenium.webdriver.common.by import By + +def test_example(selenium): + selenium.get('https://bstackdemo.com/') + + # locating product on webpage and getting name of the product + productText = selenium.find_element(By.XPATH, '//*[@id="1"]/p').text + + # clicking the 'Add to cart' button + selenium.find_element(By.XPATH, '//*[@id="1"]/div[4]').click() + + # waiting until the Cart pane has been displayed on the webpage + selenium.find_element(By.CLASS_NAME, 'float-cart__content') + + # locating product in cart and getting name of the product in cart + productCartText = selenium.find_element(By.XPATH, '//*[@id="__next"]/div/div/div[2]/div[2]/div[2]/div/div[3]/p[1]').text + + # checking whether product has been added to cart by comparing product name + assert productCartText == productText diff --git a/tests/cart/__init__.py b/tests/cart/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cart/test_cart_badge_default.py b/tests/cart/test_cart_badge_default.py new file mode 100644 index 0000000..b2d7522 --- /dev/null +++ b/tests/cart/test_cart_badge_default.py @@ -0,0 +1,8 @@ +from selenium.webdriver.common.by import By + + +def test_cart_badge_starts_at_zero(demo): + demo.go_home() + badge_value = demo.driver.find_element( + By.CSS_SELECTOR, "#shopping-cart-btn span").text.strip() + assert badge_value == "0" diff --git a/tests/cart/test_cart_checkout_visibility.py b/tests/cart/test_cart_checkout_visibility.py new file mode 100644 index 0000000..7ab9dea --- /dev/null +++ b/tests/cart/test_cart_checkout_visibility.py @@ -0,0 +1,10 @@ +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC + + +def test_checkout_button_hidden_when_logged_out(demo): + demo.add_product_to_cart(6) + demo.open_cart_page() + notice = demo.wait_for(5).until(EC.visibility_of_element_located( + (By.XPATH, "//div[contains(@class,'text-yellow-800')]"))) + assert "Please" in notice.text diff --git a/tests/cart/test_cart_clear_all.py b/tests/cart/test_cart_clear_all.py new file mode 100644 index 0000000..2cf1022 --- /dev/null +++ b/tests/cart/test_cart_clear_all.py @@ -0,0 +1,13 @@ +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC + + +def test_clear_cart_button_removes_all_items(demo): + demo.add_products_to_cart(1, 2) + demo.open_cart_page() + clear_button = demo.wait_for(5).until( + EC.element_to_be_clickable((By.ID, "clear-cart-btn"))) + clear_button.click() + empty_message = demo.wait_for(5).until(EC.visibility_of_element_located( + (By.XPATH, "//p[text()='Your cart is empty.']"))) + assert empty_message.is_displayed() diff --git a/tests/cart/test_cart_displays_items.py b/tests/cart/test_cart_displays_items.py new file mode 100644 index 0000000..b0094f2 --- /dev/null +++ b/tests/cart/test_cart_displays_items.py @@ -0,0 +1,10 @@ +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC + + +def test_cart_displays_items_added_from_catalog(demo): + demo.add_product_to_cart(4) + demo.open_cart_page() + name = demo.wait_for(5).until(EC.visibility_of_element_located( + (By.XPATH, "//div[@id='cart-item']//div[contains(@class,'font-semibold')]"))) + assert "Galaxy Note" in name.text diff --git a/tests/cart/test_cart_empty_state.py b/tests/cart/test_cart_empty_state.py new file mode 100644 index 0000000..0af53e5 --- /dev/null +++ b/tests/cart/test_cart_empty_state.py @@ -0,0 +1,10 @@ +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC + + +def test_cart_shows_empty_state_for_empty_cart(demo): + demo.login_with_random_demo_user() + demo.open_cart_page() + empty = demo.wait_for(5).until(EC.visibility_of_element_located( + (By.XPATH, "//p[contains(text(),'Your cart is empty.')]"))) + assert empty.is_displayed() diff --git a/tests/cart/test_cart_quantity_adjustment.py b/tests/cart/test_cart_quantity_adjustment.py new file mode 100644 index 0000000..290972c --- /dev/null +++ b/tests/cart/test_cart_quantity_adjustment.py @@ -0,0 +1,17 @@ +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC + + +def test_increment_and_decrement_buttons_update_quantity(demo): + demo.add_product_to_cart(5) + demo.open_cart_page() + quantity_locator = ( + By.XPATH, "//div[@id='cart-item']//span[contains(@id,'item-quantity')]") + plus_button = demo.driver.find_element( + By.XPATH, "//div[@id='cart-item']//button[text()='+']") + minus_button = demo.driver.find_element( + By.XPATH, "//div[@id='cart-item']//button[text()='-']") + plus_button.click() + demo.wait_for(5).until(EC.text_to_be_present_in_element(quantity_locator, "2")) + minus_button.click() + demo.wait_for(5).until(EC.text_to_be_present_in_element(quantity_locator, "1")) diff --git a/tests/cart/test_cart_remove_item.py b/tests/cart/test_cart_remove_item.py new file mode 100644 index 0000000..a55b9f7 --- /dev/null +++ b/tests/cart/test_cart_remove_item.py @@ -0,0 +1,12 @@ +from selenium.webdriver.common.by import By + + +def test_remove_item_link_removes_single_item(demo): + demo.add_products_to_cart(7, 8) + demo.open_cart_page() + before_items = demo.driver.find_elements(By.ID, "cart-item") + demo.driver.find_element( + By.XPATH, "(//div[@id='cart-item']//button[contains(text(),'Remove')])[1]").click() + demo.wait_for(5).until( + lambda d: len(d.find_elements(By.ID, "cart-item")) == len(before_items) - 1 + ) diff --git a/tests/checkout/__init__.py b/tests/checkout/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/checkout/test_checkout_invoice_modal.py b/tests/checkout/test_checkout_invoice_modal.py new file mode 100644 index 0000000..875aa54 --- /dev/null +++ b/tests/checkout/test_checkout_invoice_modal.py @@ -0,0 +1,21 @@ +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC + + +def test_invoice_modal_displays_line_items(demo): + demo.login_with_random_demo_user() + demo.add_product_to_cart(2) + demo.open_cart_page() + demo.driver.find_element(By.ID, "checkout-btn").click() + demo.wait_for(5).until( + EC.element_to_be_clickable((By.ID, "place-order-btn"))).click() + demo.wait_for(10).until(EC.url_contains("/orders")) + first_order = demo.wait_for(5).until( + EC.visibility_of_element_located((By.CSS_SELECTOR, "li.order-list-item"))) + order_id = first_order.get_attribute("data-order-id") + demo.driver.find_element(By.ID, f"view-invoice-btn-{order_id}").click() + modal_header = demo.wait_for(5).until(EC.visibility_of_element_located( + (By.XPATH, "//h2[contains(text(),'Invoice for Order')]"))) + assert order_id in modal_header.text + assert demo.driver.find_elements( + By.XPATH, "//div[contains(@class,'shadow-lg')]//li") != [] diff --git a/tests/checkout/test_checkout_order_history.py b/tests/checkout/test_checkout_order_history.py new file mode 100644 index 0000000..2a3d9e4 --- /dev/null +++ b/tests/checkout/test_checkout_order_history.py @@ -0,0 +1,13 @@ +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC + + +def test_can_place_order_and_reach_order_history(demo): + demo.login_with_random_demo_user() + demo.add_product_to_cart(1) + demo.open_cart_page() + demo.driver.find_element(By.ID, "checkout-btn").click() + demo.wait_for(5).until( + EC.visibility_of_element_located((By.ID, "place-order-btn"))).click() + demo.wait_for(10).until(EC.url_contains("/orders")) + assert demo.driver.find_elements(By.CSS_SELECTOR, "li.order-list-item") != [] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f5f3510 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +import pytest + +from tests.demo_app.helpers import DemoApp + + +@pytest.fixture +def demo(selenium): + return DemoApp(selenium) diff --git a/tests/demo_app/__init__.py b/tests/demo_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/demo_app/helpers.py b/tests/demo_app/helpers.py new file mode 100644 index 0000000..8c17d2e --- /dev/null +++ b/tests/demo_app/helpers.py @@ -0,0 +1,163 @@ +import os +import random +import time + +from selenium.common.exceptions import ElementClickInterceptedException +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import Select, WebDriverWait +from selenium.webdriver.support import expected_conditions as EC + + +DEFAULT_APP_URL = "https://demo-app-sts.surge.sh" + + +class DemoApp: + """Shared helpers for interacting with the BrowserStack demo storefront. + + Python/pytest port of the Java BaseUiTest helper class. + """ + + def __init__(self, driver): + self.driver = driver + + def base_url(self): + configured = os.environ.get("APP_URL") + if configured and configured.strip(): + return configured.rstrip("/") + return DEFAULT_APP_URL + + def open_path(self, path): + normalized = path if path.startswith("/") else "/" + path + self.driver.get(self.base_url() + normalized) + time.sleep(5) + + def wait_for(self, seconds): + return WebDriverWait(self.driver, seconds) + + def go_home(self): + self.open_path("/") + self.wait_for(30).until(EC.visibility_of_element_located((By.ID, "hero-title"))) + + def open_scenarios_page(self): + self.open_path("/scenarios") + self.wait_for(30).until(EC.visibility_of_element_located( + (By.XPATH, "//h1[contains(text(),'Scenarios')]"))) + + def open_login_page(self): + self.open_path("/login") + self.wait_for(30).until( + lambda d: "/login" in d.current_url or "/profile" in d.current_url + ) + + def login_as(self, email, password): + self.open_login_page() + wait = self.wait_for(10) + email_input = wait.until(EC.element_to_be_clickable((By.ID, "email-input"))) + email_input.clear() + email_input.send_keys(email) + password_input = self.driver.find_element(By.ID, "password-input") + password_input.clear() + password_input.send_keys(password) + self.driver.find_element(By.ID, "login-submit").click() + wait.until(EC.url_contains("/products")) + wait.until(EC.visibility_of_element_located((By.ID, "shopping-cart-btn"))) + + def login_with_random_demo_user(self): + self.open_login_page() + select = self._user_select() + choices = self._selectable_user_options(select) + if not choices: + raise RuntimeError("No demo users found in the login dropdown") + email = random.choice(choices).get_attribute("value") + self._complete_quick_select_login(select, email) + + def login_using_quick_select(self, email): + self.open_login_page() + select = self._user_select() + self._complete_quick_select_login(select, email) + + def first_demo_user_email(self): + self.open_login_page() + options = self._selectable_user_options(self._user_select()) + if not options: + raise RuntimeError("No demo users found in the login dropdown") + return options[0].get_attribute("value") + + def open_products_page(self): + self.open_path("/products") + self.wait_for(30).until(EC.visibility_of_element_located( + (By.CSS_SELECTOR, "[id^='product-card-']"))) + + def open_profile_page(self): + self.open_path("/profile") + self.wait_for(30).until( + lambda d: "/profile" in d.current_url or "/login" in d.current_url + ) + + def open_orders_page(self): + self.open_path("/orders") + self.wait_for(30).until( + lambda d: "/orders" in d.current_url or "/login" in d.current_url + ) + + def add_product_to_cart(self, product_id, quantity=1): + self.open_products_page() + wait = self.wait_for(10) + card = wait.until(EC.visibility_of_element_located( + (By.ID, f"product-card-{product_id}"))) + quantity_field = card.find_element(By.CSS_SELECTOR, "input[type='number']") + quantity_field.clear() + quantity_field.send_keys(str(quantity)) + before = self.cart_badge_count() + add_button = card.find_element(By.ID, f"add-to-cart-{product_id}") + wait.until(EC.element_to_be_clickable(add_button)).click() + wait.until(EC.text_to_be_present_in_element( + (By.CSS_SELECTOR, "#shopping-cart-btn span"), str(before + quantity))) + + def add_products_to_cart(self, *product_ids): + for pid in product_ids: + self.add_product_to_cart(pid) + + def cart_badge_count(self): + text = self.driver.find_element( + By.CSS_SELECTOR, "#shopping-cart-btn span").text.strip() + try: + return int(text) + except ValueError: + return 0 + + def open_cart_page(self): + self.driver.find_element(By.ID, "shopping-cart-btn").click() + self.wait_for(10).until(EC.visibility_of_element_located( + (By.XPATH, "//h1[contains(text(),'Shopping Cart')]"))) + + def _user_select(self): + wait = self.wait_for(10) + element = wait.until(EC.visibility_of_element_located((By.ID, "user-select"))) + return Select(element) + + def _selectable_user_options(self, select): + return [opt for opt in select.options + if opt.get_attribute("value") and opt.get_attribute("value").strip()] + + def _complete_quick_select_login(self, select, email): + wait = self.wait_for(10) + select.select_by_value(email) + wait.until(EC.text_to_be_present_in_element_value((By.ID, "email-input"), email)) + self.driver.find_element(By.ID, "login-submit").click() + wait.until(EC.url_contains("/products")) + + def scroll_into_view(self, element): + if element is None: + return + self.driver.execute_script( + "arguments[0].scrollIntoView({block: 'center', inline: 'center'});", element) + time.sleep(0.2) + + def click_with_retry(self, element): + if element is None: + return + try: + element.click() + except ElementClickInterceptedException: + self.driver.execute_script("arguments[0].click();", element) diff --git a/tests/hero/__init__.py b/tests/hero/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/hero/test_hero_section_copy.py b/tests/hero/test_hero_section_copy.py new file mode 100644 index 0000000..1562509 --- /dev/null +++ b/tests/hero/test_hero_section_copy.py @@ -0,0 +1,9 @@ +from selenium.webdriver.common.by import By + + +def test_hero_section_displays_expected_copy(demo): + demo.go_home() + hero_title = demo.driver.find_element(By.ID, "hero-title") + hero_subtitle = demo.driver.find_element(By.ID, "hero-subtitle") + assert "Demo Playground" in hero_title.text + assert "BrowserStack-ready" in hero_subtitle.text diff --git a/tests/orderhistory/__init__.py b/tests/orderhistory/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/orderhistory/test_order_history_guard.py b/tests/orderhistory/test_order_history_guard.py new file mode 100644 index 0000000..1a1eafe --- /dev/null +++ b/tests/orderhistory/test_order_history_guard.py @@ -0,0 +1,6 @@ +from selenium.webdriver.common.by import By + + +def test_order_history_route_is_guarded(demo): + demo.open_orders_page() + assert demo.driver.find_element(By.ID, "login-submit").is_displayed() diff --git a/tests/orderhistory/test_order_history_latest_entry.py b/tests/orderhistory/test_order_history_latest_entry.py new file mode 100644 index 0000000..6a0c277 --- /dev/null +++ b/tests/orderhistory/test_order_history_latest_entry.py @@ -0,0 +1,24 @@ +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC + + +def _place_order_for_product(demo, product_id): + demo.add_product_to_cart(product_id) + demo.open_cart_page() + demo.driver.find_element(By.ID, "checkout-btn").click() + demo.wait_for(5).until( + EC.element_to_be_clickable((By.ID, "place-order-btn"))).click() + demo.wait_for(10).until(EC.url_contains("/orders")) + + +def test_latest_order_appears_first(demo): + demo.login_with_random_demo_user() + _place_order_for_product(demo, 3) + first_order_id = demo.wait_for(5).until( + EC.visibility_of_element_located( + (By.CSS_SELECTOR, "li.order-list-item"))).get_attribute("data-order-id") + _place_order_for_product(demo, 4) + latest_order_id = demo.wait_for(5).until( + EC.visibility_of_element_located( + (By.CSS_SELECTOR, "li.order-list-item"))).get_attribute("data-order-id") + assert latest_order_id != first_order_id diff --git a/tests/product/__init__.py b/tests/product/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/product/test_product_add_to_cart_badge.py b/tests/product/test_product_add_to_cart_badge.py new file mode 100644 index 0000000..b767c51 --- /dev/null +++ b/tests/product/test_product_add_to_cart_badge.py @@ -0,0 +1,5 @@ +def test_add_to_cart_updates_badge_count(demo): + demo.open_products_page() + before = demo.cart_badge_count() + demo.add_product_to_cart(1) + assert demo.cart_badge_count() == before + 1 diff --git a/tests/product/test_product_catalog_default_load.py b/tests/product/test_product_catalog_default_load.py new file mode 100644 index 0000000..722ce62 --- /dev/null +++ b/tests/product/test_product_catalog_default_load.py @@ -0,0 +1,10 @@ +from selenium.webdriver.common.by import By + + +def test_catalog_default_shows_all_products(demo): + demo.open_products_page() + demo.wait_for(5).until( + lambda d: len(d.find_elements(By.CSS_SELECTOR, "[id^='product-card-']")) == 10 + ) + total_cards = len(demo.driver.find_elements(By.CSS_SELECTOR, "[id^='product-card-']")) + assert total_cards == 10, "Default catalog load should render 10 products" diff --git a/tests/product/test_product_filter_android.py b/tests/product/test_product_filter_android.py new file mode 100644 index 0000000..3ef8ba3 --- /dev/null +++ b/tests/product/test_product_filter_android.py @@ -0,0 +1,14 @@ +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import Select + + +def test_category_filter_limits_results_for_android(demo): + demo.open_products_page() + category_select = Select(demo.driver.find_element(By.CSS_SELECTOR, "select")) + category_select.select_by_value("android") + demo.wait_for(5).until( + lambda d: len(d.find_elements(By.ID, "product-card-2")) == 0 + ) + visible_cards = len(demo.driver.find_elements( + By.CSS_SELECTOR, "[id^='product-card-']")) + assert visible_cards == 8, "Filtered catalog should show exactly 8 results" diff --git a/tests/product/test_product_filter_ios.py b/tests/product/test_product_filter_ios.py new file mode 100644 index 0000000..f69b03d --- /dev/null +++ b/tests/product/test_product_filter_ios.py @@ -0,0 +1,14 @@ +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import Select + + +def test_category_filter_limits_results_for_ios(demo): + demo.open_products_page() + category_select = Select(demo.driver.find_element(By.CSS_SELECTOR, "select")) + category_select.select_by_value("ios") + demo.wait_for(5).until( + lambda d: len(d.find_elements(By.ID, "product-card-1")) == 0 + ) + visible_cards = len(demo.driver.find_elements( + By.CSS_SELECTOR, "[id^='product-card-']")) + assert visible_cards == 2, "Filtered catalog should show exactly two results" diff --git a/tests/product/test_product_multi_quantity_cart.py b/tests/product/test_product_multi_quantity_cart.py new file mode 100644 index 0000000..0ffc545 --- /dev/null +++ b/tests/product/test_product_multi_quantity_cart.py @@ -0,0 +1,10 @@ +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC + + +def test_adding_multiple_quantity_persists_in_cart(demo): + demo.add_product_to_cart(3, quantity=3) + demo.open_cart_page() + cart_item = demo.wait_for(5).until( + EC.visibility_of_element_located((By.ID, "cart-item"))) + assert "x 3" in cart_item.text diff --git a/tests/product/test_product_remove_from_cart_button.py b/tests/product/test_product_remove_from_cart_button.py new file mode 100644 index 0000000..ff15829 --- /dev/null +++ b/tests/product/test_product_remove_from_cart_button.py @@ -0,0 +1,13 @@ +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC + + +def test_remove_from_cart_button_appears_for_items(demo): + demo.add_product_to_cart(2) + remove_button = demo.wait_for(5).until( + EC.visibility_of_element_located((By.ID, "remove-from-cart-2"))) + before = demo.cart_badge_count() + remove_button.click() + demo.wait_for(5).until(EC.text_to_be_present_in_element( + (By.CSS_SELECTOR, "#shopping-cart-btn span"), str(before - 1))) + assert demo.driver.find_elements(By.ID, "remove-from-cart-2") == [] diff --git a/tests/product/test_product_search_filter.py b/tests/product/test_product_search_filter.py new file mode 100644 index 0000000..3c1a81c --- /dev/null +++ b/tests/product/test_product_search_filter.py @@ -0,0 +1,15 @@ +import time + +from selenium.webdriver.common.by import By + + +def test_search_filters_results(demo): + demo.open_products_page() + search_input = demo.driver.find_element( + By.CSS_SELECTOR, "input[placeholder='Search products...']") + search_input.clear() + search_input.send_keys("pixel") + time.sleep(0.5) + visible_cards = len(demo.driver.find_elements( + By.CSS_SELECTOR, "[id^='product-card-']")) + assert visible_cards == 1, "Filtered catalog should show exactly 1 result" diff --git a/tests/profile/__init__.py b/tests/profile/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/profile/test_profile_link_guard.py b/tests/profile/test_profile_link_guard.py new file mode 100644 index 0000000..2bc6a55 --- /dev/null +++ b/tests/profile/test_profile_link_guard.py @@ -0,0 +1,8 @@ +from selenium.webdriver.common.by import By + + +def test_profile_link_redirects_to_login_when_logged_out(demo): + demo.go_home() + demo.driver.find_element(By.ID, "profile-btn").click() + demo.wait_for(10).until(lambda d: "/login" in d.current_url) + assert demo.driver.find_element(By.ID, "login-title").text == "Welcome Back" diff --git a/tests/scenarios/__init__.py b/tests/scenarios/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/scenarios/test_scenario_clipboard_toast.py b/tests/scenarios/test_scenario_clipboard_toast.py new file mode 100644 index 0000000..2dbd303 --- /dev/null +++ b/tests/scenarios/test_scenario_clipboard_toast.py @@ -0,0 +1,14 @@ +import time + +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC + + +def test_clipboard_button_shows_link_copied_toast(demo): + demo.open_scenarios_page() + copy_button = demo.wait_for(5).until(EC.element_to_be_clickable( + (By.XPATH, "//button[contains(text(),'Copy share link')]"))) + copy_button.click() + time.sleep(0.5) + copied_value = demo.driver.find_element(By.ID, "share-copy-result").text.strip() + assert "https://demo-app.local/scenario" in copied_value diff --git a/tests/scenarios/test_scenario_feature_toggle.py b/tests/scenarios/test_scenario_feature_toggle.py new file mode 100644 index 0000000..181e8d9 --- /dev/null +++ b/tests/scenarios/test_scenario_feature_toggle.py @@ -0,0 +1,14 @@ +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC + + +def test_feature_toggle_changes_visual_state(demo): + demo.open_scenarios_page() + toggle_button = demo.wait_for(5).until( + EC.element_to_be_clickable((By.ID, "feature-toggle-btn"))) + initial_class = toggle_button.get_attribute("class") + toggle_button.click() + demo.wait_for(5).until( + lambda d: toggle_button.get_attribute("class") != initial_class + ) + assert "bg-blue-" in toggle_button.get_attribute("class") diff --git a/tests/scenarios/test_scenario_nested_xpath_input.py b/tests/scenarios/test_scenario_nested_xpath_input.py new file mode 100644 index 0000000..975a396 --- /dev/null +++ b/tests/scenarios/test_scenario_nested_xpath_input.py @@ -0,0 +1,10 @@ +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC + + +def test_nested_xpath_input_is_interactable(demo): + demo.open_scenarios_page() + nested_input = demo.wait_for(5).until( + EC.visibility_of_element_located((By.ID, "xpath-input"))) + nested_input.send_keys("Nested input text") + assert nested_input.get_attribute("value") == "Nested input text" diff --git a/tests/scenarios/test_scenario_notification_toast.py b/tests/scenarios/test_scenario_notification_toast.py new file mode 100644 index 0000000..0789eef --- /dev/null +++ b/tests/scenarios/test_scenario_notification_toast.py @@ -0,0 +1,18 @@ +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC + + +def _wait_for_toast_with_title(demo, title_text): + return demo.wait_for(5).until(EC.visibility_of_element_located(( + By.XPATH, + f"//div[contains(@class,'font-semibold') and contains(normalize-space(), '{title_text}')]" + ))) + + +def test_notification_button_shows_toast(demo): + demo.open_scenarios_page() + submit_button = demo.wait_for(5).until(EC.element_to_be_clickable( + (By.CSS_SELECTOR, "button[title='Submit']"))) + submit_button.click() + toast = _wait_for_toast_with_title(demo, "Demo notification") + assert toast.is_displayed(), "Toast title should be visible" diff --git a/tests/scenarios/test_scenario_progress_buttons.py b/tests/scenarios/test_scenario_progress_buttons.py new file mode 100644 index 0000000..346ec88 --- /dev/null +++ b/tests/scenarios/test_scenario_progress_buttons.py @@ -0,0 +1,15 @@ +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC + + +def test_progress_buttons_update_status(demo): + demo.open_scenarios_page() + demo.driver.find_element(By.ID, "progress-btn-100").click() + status = demo.wait_for(5).until( + EC.visibility_of_element_located((By.ID, "progress-status-id"))) + assert "Complete" in status.text + + demo.driver.find_element(By.ID, "progress-btn-50").click() + status_in_progress = demo.wait_for(5).until( + EC.visibility_of_element_located((By.ID, "progress-status-id"))) + assert "In Progress" in status_in_progress.text diff --git a/tests/scenarios/test_scenario_static_id_input.py b/tests/scenarios/test_scenario_static_id_input.py new file mode 100644 index 0000000..9d3bb9d --- /dev/null +++ b/tests/scenarios/test_scenario_static_id_input.py @@ -0,0 +1,11 @@ +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC + + +def test_static_id_field_accepts_input(demo): + demo.open_scenarios_page() + static_field = demo.wait_for(5).until( + EC.visibility_of_element_located((By.ID, "static-id-field"))) + static_field.clear() + static_field.send_keys("ORD-2025-001") + assert static_field.get_attribute("value") == "ORD-2025-001" diff --git a/tests/scenarios/test_scenarios_cta_navigation.py b/tests/scenarios/test_scenarios_cta_navigation.py new file mode 100644 index 0000000..cba3fba --- /dev/null +++ b/tests/scenarios/test_scenarios_cta_navigation.py @@ -0,0 +1,8 @@ +from selenium.webdriver.common.by import By + + +def test_primary_cta_navigates_to_scenarios(demo): + demo.go_home() + demo.driver.find_element(By.ID, "primary-cta").click() + demo.wait_for(10).until(lambda d: "/scenarios" in d.current_url) + assert demo.driver.find_element(By.ID, "static-id-field").is_displayed() diff --git a/tests/test_local_simple.py b/tests/test_local_simple.py new file mode 100644 index 0000000..d1c38b7 --- /dev/null +++ b/tests/test_local_simple.py @@ -0,0 +1,13 @@ +def test_addition(): + assert 1 + 1 == 2 + +def test_string_concat(): + assert "hello" + " " + "world" == "hello world" + +def test_list_ops(): + nums = [1, 2, 3] + nums.append(4) + assert nums == [1, 2, 3, 4] + +def test_fail_example(): + assert True