selenium-webdrivergithub-actionsui-automationheadlesswebautomation

TimeoutExeption in Headless mode


I’m working on a Python project using Selenium and pytest. All tests pass locally in normal (non-headless) mode, but when I run them in GitHub Actions or even locally in headless mode, many of them start failing. I’ve already added several JavaScript-based workarounds, but they don’t help. Could you please advise what I should try next?

Test Results

============================= test session starts =============================
collecting ... collected 1 item

tests/test_header_click.py::test_header_clicks 

============================= 1 failed in 38.27s ==============================
FAILED                    [100%]
tests\test_header_click.py:6 (test_header_clicks)
driver = <selenium.webdriver.chrome.webdriver.WebDriver (session="c4abb5b393cb24809aa039173f966654")>

    @allure.feature("Header")
    @allure.story("Top Navigation")
    @allure.title("Verify all header menu links are clickable and open correct pages")
    @pytest.mark.smoke
    def test_header_clicks(driver):
    
        header = HeaderPage(driver)
    
        items = [
            (header.click_magazine, "magazine", True),
            (header.click_reviews, "reviews", True),
            (header.click_support, "support", True),
            (header.click_profile_icon, "my-account", True)
        ]
    
        for action, expected, need_back in items:
            with allure.step(f"Check header link: {expected}"):
>               action()

tests\test_header_click.py:24: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
pages\header_page.py:28: in click_magazine
    self.click(self.MAGAZINE)
pages\base_page.py:31: in click
    element = self.wait.until(EC.element_to_be_clickable(locator))
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <selenium.webdriver.support.wait.WebDriverWait (session="c4abb5b393cb24809aa039173f966654")>
method = <function element_to_be_clickable.<locals>._predicate at 0x000002235C2B07D0>
message = ''

    def until(self, method: Callable[[D], Union[Literal[False], T]], message: str = "") -> T:
        """Wait until the method returns a value that is not False.
    
        Calls the method provided with the driver as an argument until the
        return value does not evaluate to ``False``.
    
        Parameters:
        -----------
        method: callable(WebDriver)
            - A callable object that takes a WebDriver instance as an argument.
    
        message: str
            - Optional message for :exc:`TimeoutException`
    
        Return:
        -------
        object: T
            - The result of the last call to `method`
    
        Raises:
        -------
        TimeoutException
            - If 'method' does not return a truthy value within the WebDriverWait
            object's timeout
    
        Example:
        --------
        >>> from selenium.webdriver.common.by import By
        >>> from selenium.webdriver.support.ui import WebDriverWait
        >>> from selenium.webdriver.support import expected_conditions as EC
    
        # Wait until an element is visible on the page
        >>> wait = WebDriverWait(driver, 10)
        >>> element = wait.until(EC.visibility_of_element_located((By.ID, "exampleId")))
        >>> print(element.text)
        """
        screen = None
        stacktrace = None
    
        end_time = time.monotonic() + self._timeout
        while True:
            try:
                value = method(self._driver)
                if value:
                    return value
            except self._ignored_exceptions as exc:
                screen = getattr(exc, "screen", None)
                stacktrace = getattr(exc, "stacktrace", None)
            if time.monotonic() > end_time:
                break
            time.sleep(self._poll)
>       raise TimeoutException(message, screen, stacktrace)
E       selenium.common.exceptions.TimeoutException: Message:

.venv\Lib\site-packages\selenium\webdriver\support\wait.py:138: TimeoutException

Process finished with exit code 1

helpers/browser.py

from selenium import webdriver
from selenium.webdriver.chrome.options import Options

def get_driver():
    options = Options()

    options.add_argument("--window-size=1920,1080")
    options.add_argument("--no-sandbox")
    options.add_argument("--disable-gpu")
    options.add_argument("--disable-dev-shm-usage")
    options.add_argument("--headless=new")

    driver = webdriver.Chrome(options=options)

    driver.implicitly_wait(5)
    return driver

pages/base_page.py

import time

from selenium.webdriver import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

from utils.popup_handler import PopupHandler


class BasePage:
    def __init__(self, driver, timeout=15):
        self.driver = driver
        self.wait = WebDriverWait(driver, timeout)
        self.popups = PopupHandler(driver)

    #locators

    def pause(self, seconds):
        time.sleep(seconds)

    def open(self, url):
        self.driver.get(url)

    def click(self, locator):
        element = self.wait.until(EC.presence_of_element_located(locator))
        self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", element)
        element = self.wait.until(EC.element_to_be_clickable(locator))
        element.click()

    def scroll_to(self, locator):
        element = self.wait.until(EC.presence_of_element_located(locator))
        self.driver.execute_script("""
            const rect = arguments[0].getBoundingClientRect();
            window.scrollBy({top: rect.top - window.innerHeight/3, behavior: 'smooth'});
        """, element)

    def type(self, locator, text):
        element = self.wait.until(EC.visibility_of_element_located(locator))
        element.clear()
        element.send_keys(text)


    def get_text(self, locator):
        element = self.wait.until(EC.visibility_of_element_located(locator))
        return element.text

    def is_visible(self, locator):
        try:
            self.wait.until(EC.visibility_of_element_located(locator))
            return True
        except:
            return False

    def hover(self, locator):
        # Ensure element is visible and scrolled into view before hovering
        element = self.wait.until(EC.presence_of_element_located(locator))
        self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", element)
        element = self.wait.until(EC.visibility_of_element_located(locator))
        # Use JavaScript hover for better headless mode compatibility
        # Trigger both mouseover and mouseenter events to ensure dropdowns appear
        self.driver.execute_script("""
            var element = arguments[0];
            var mouseover = new MouseEvent('mouseover', {bubbles: true, cancelable: true});
            var mouseenter = new MouseEvent('mouseenter', {bubbles: true, cancelable: true});
            element.dispatchEvent(mouseover);
            element.dispatchEvent(mouseenter);
        """, element)
        # Also try ActionChains as backup
        try:
            ActionChains(self.driver).move_to_element(element).perform()
        except Exception:
            pass  # JavaScript hover already executed

    def check_availability(self):
        self.click(self.AVAILABILITY_BUTTON)

    def add_to_cart(self):
        element = self.wait.until(EC.presence_of_element_located(self.ADD_TO_CART_BUTTON))
        self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", element)
        element = self.wait.until(EC.element_to_be_clickable(self.ADD_TO_CART_BUTTON))
        self.driver.execute_script("arguments[0].click();", element)
        self.wait_for_cart_popup()

    def close_cart_popup(self):
        self.pause(0.5)
        self.click(self.CART_CLOSE_BUTTON)

    def open_cart(self):
        self.pause(1.5)
        element = self.wait.until(EC.presence_of_element_located(self.VIEW_CART_POPUP))
        self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", element)
        self.wait.until(EC.element_to_be_clickable(self.VIEW_CART_POPUP))
        self.driver.execute_script("arguments[0].click();", element)

    def clean_popups(self):
        try:
            self.popups.clean()
        except Exception as e:
            print(f"[Popups] clean() failed but ignored: {e}")

    def wait_for_cart_popup(self, timeout=10):
        WebDriverWait(self.driver, timeout).until(
            EC.visibility_of_element_located(self.VIEW_CART_POPUP)
        )

tests/test_header_click.py

import allure
from pages.header_page import HeaderPage
import pytest

@allure.feature("Header")
@allure.story("Top Navigation")
@allure.title("Verify all header menu links are clickable and open correct pages")
@pytest.mark.smoke
def test_header_clicks(driver):

    header = HeaderPage(driver)

    items = [
        (header.click_magazine, "magazine", True),
        (header.click_reviews, "reviews", True),
        (header.click_support, "support", True),
        (header.click_profile_icon, "my-account", True)
    ]

    for action, expected, need_back in items:
        with allure.step(f"Check header link: {expected}"):
            action()
            assert expected in driver.current_url.lower()
            if need_back:
                driver.back()

    with allure.step("Click logo to return to homepage"):
        header.click_logo()
        assert "example.com" in driver.current_url.lower()

pages/header_page.py

from selenium.webdriver.support import expected_conditions as EC

from selenium.webdriver.common.by import By
from pages.base_page import BasePage

class HeaderPage(BasePage):

    #HEADER
    LOGO = (By.XPATH, "//a[@class='logo']")

    PRODUCTS = (By.XPATH, "//*[@id='menu-item-112235']")

    MAGAZINE = (By.CSS_SELECTOR, "header a[href*='/category/particle-magazine/']")
    REVIEWS = (By.CSS_SELECTOR, "header a[href*='/particle-reviews/']")
    SUPPORT = (By.CSS_SELECTOR, "header a[href*='/faq-support']")

    PROFILE_ICON = (By.XPATH, "//*[@class='btn btn-transparent login ']")
    CART_ICON = (By.XPATH, "//*[@class='shopping-basket']")

    # METHODS CLICK HEADER

    def click_logo(self):
        self.click(self.LOGO)

    def click_magazine(self):
        self.click(self.MAGAZINE)

    def click_reviews(self):
        self.click(self.REVIEWS)

    def click_support(self):
        self.click(self.SUPPORT)

    def click_profile_icon(self):
        self.click(self.PROFILE_ICON)

    def click_cart_icon(self):
        self.click(self.CART_ICON)

    # HOVERs
    def hover_support(self):
        self.hover(self.SUPPORT)
        try:
            submenu = (By.CSS_SELECTOR, "#menu-item-534292 .sub-menu, #menu-item-534292 ul")
            self.wait.until(EC.visibility_of_element_located(submenu))
        except Exception:
            self.pause(0.5)

    def hover_products(self):
        self.hover(self.PRODUCTS)
        try:
            submenu = (By.CSS_SELECTOR, "#menu-item-112235 .sub-menu, #menu-item-112235 ul")
            self.wait.until(EC.visibility_of_element_located(submenu))
        except Exception:
            self.pause(0.5)

GitHub Actions workflow

name: UI tests - regression

on:
  schedule:
    - cron: "0 5 * * *"
  workflow_dispatch:

jobs:
  regression-tests:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repo
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Cache pip
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
          restore-keys: |
            ${{ runner.os }}-pip-

      - name: Install Chrome
        uses: browser-actions/setup-chrome@v2
        with:
          chrome-version: stable
          install-chromedriver: false

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Run pytest (regression)
        run: |
          pytest -m "regression"

      - name: Upload Allure results (regression)
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: allure-results-regression
          path: allure-results

Tests run fine locally in normal mode on Windows. On Ubuntu or in headless mode, they usually fail with different exceptions like TimeoutException or ElementClickInterceptedException: element click intercepted.

In normal mode, everything works well and all tests pass.


Solution

  • It's most likely because the -window-size=1920,1080 no longer works in Chrome when in headless mode, so it's running at a different size than when not in headless mode. You should use --screen-info={0,0 1920x1080} or else resize the Window after you launch the browser. See: https://issues.chromium.org/issues/423334494

    Also, you should remove all the JavaScript click workarounds and driver.implicitly_wait(5) (you are overriding the explicit waits you are trying to use).