Фреймы, вкладки и алерты

Элемент виден на экране, а find_element уверяет, что его нет. Скорее всего, вы забыли переключить контекст — и этот урок ровно об этом.

Контекст просмотра (browsing context) — это отдельный «мир» браузера: вкладка, окно или iframe со своим DOM. WebDriver в каждый момент времени смотрит ровно в один контекст, и все команды поиска работают только внутри него.

Зачем это нужно на практике

Три вещи в вебе живут вне вашего документа. Первое — iframe: платёжная форма банка, капча, виджет чата, WYSIWYG-редактор, встроенное видео. Второе — новая вкладка: ссылка с target="_blank", OAuth-логин через соцсеть, «Скачать счёт» в отдельном окне. Третье — нативный алерт: тот самый серый прямоугольник от браузера с «ОК» и «Отмена», который не является частью страницы вообще. Все три случая ломают наивный тест одинаково: NoSuchElementException на элементе, который прекрасно виден человеку. Лечится это не хитрым локатором, а переключением контекста.

Фреймы: switch_to.frame и дорога обратно

Внутри iframe лежит другой документ. Пока драйвер стоит в главном документе, содержимое фрейма для него — чёрный ящик. Переключаемся:

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

wait = WebDriverWait(driver, 10)

# лучший способ: дождаться фрейма и переключиться в него одной командой
wait.until(EC.frame_to_be_available_and_switch_to_it(
    (By.CSS_SELECTOR, "iframe[title='Оплата картой']")
))

# мы внутри фрейма: ищем только его элементы
driver.find_element(By.ID, "card-number").send_keys("4111111111111111")
driver.find_element(By.ID, "card-expiry").send_keys("1230")
driver.find_element(By.ID, "card-cvc").send_keys("123")

# ВОЗВРАЩАЕМСЯ НАВЕРХ — иначе кнопка "Оплатить" из основной страницы не найдётся
driver.switch_to.default_content()
driver.find_element(By.ID, "pay").click()

Переключиться во фрейм можно тремя способами: по WebElement (как выше через ожидание — самый надёжный), по name или id строкой (driver.switch_to.frame("payment")) и по порядковому номеру (driver.switch_to.frame(0) — хрупко, номер меняется от рекламы и A/B-тестов).

Возврат — три разных метода, и их путают:

МетодКуда попадаем
driver.switch_to.default_content()в самый верхний документ страницы, из любой глубины вложенности
driver.switch_to.parent_frame()на один уровень выше — в родительский фрейм
driver.switch_to.frame(...)на уровень ниже — внутрь указанного фрейма

Фреймы бывают вложенными (виджет внутри виджета), и тогда путь строится по шагам: frame("outer"), затем frame(0). Чтобы не забывать про возврат, заведите контекстный менеджер — он вернёт драйвер наверх даже при падении ассерта внутри блока:

from contextlib import contextmanager

@contextmanager
def inside_frame(driver, locator, timeout=10):
    WebDriverWait(driver, timeout).until(
        EC.frame_to_be_available_and_switch_to_it(locator)
    )
    try:
        yield
    finally:
        driver.switch_to.default_content()   # выполнится даже если внутри упал assert

with inside_frame(driver, (By.CSS_SELECTOR, "iframe#payment")):
    driver.find_element(By.ID, "card-number").send_keys("4111111111111111")

driver.find_element(By.ID, "pay").click()   # мы уже гарантированно наверху

Вкладки и окна: window_handles

Клик по ссылке с target="_blank" открывает новую вкладку — но драйвер остаётся в старой. Он не «перепрыгивает» за пользователем: переключение всегда явное. Каждой вкладке браузер выдаёт непрозрачный идентификатор — window handle, обычная строка вида CDwindow-9F2A....

main = driver.current_window_handle          # запомнили, откуда ушли

driver.find_element(By.LINK_TEXT, "Открыть документацию").click()
wait.until(EC.number_of_windows_to_be(2))    # ждём именно появления вкладки, не sleep

new_tab = [h for h in driver.window_handles if h != main][0]
driver.switch_to.window(new_tab)

assert "Документация" in driver.title
print(driver.current_url)

driver.close()                 # закрыли новую вкладку
driver.switch_to.window(main)  # ОБЯЗАТЕЛЬНО вернулись: иначе NoSuchWindowException

Разберём по шагам. number_of_windows_to_be(2) — правильное ожидание: вкладка появляется не мгновенно, а window_handles, прочитанный сразу после клика, вернёт ещё одну запись. Дальше мы вычисляем «новый» handle как тот, которого не было раньше, — порядок в списке не гарантирован спецификацией, полагаться на [-1] рискованно. И, наконец, close() закрывает текущую вкладку, но не переносит фокус драйвера: после него драйвер указывает на закрытое окно, и любая следующая команда упадёт с NoSuchWindowException. Поэтому switch_to.window(main) идёт следом всегда.

В Selenium 4 появилась и обратная возможность — открыть вкладку самим, без клика по ссылке. Удобно, когда нужно параллельно посмотреть письмо в почтовом клиенте или админку:

driver.switch_to.new_window("tab")     # или "window" — отдельное окно
driver.get("https://mail.example.com/inbox")
code = driver.find_element(By.CSS_SELECTOR, ".otp-code").text

driver.close()
driver.switch_to.window(main)
driver.find_element(By.ID, "otp").send_keys(code)

Алерты: нативные окна браузера

Нативный алерт (alert, confirm, prompt) — это не элемент DOM. Его нельзя найти локатором, по нему нельзя кликнуть мышью, он вообще не принадлежит странице. Пока он открыт, страница заблокирована, и большинство команд WebDriver вернут UnexpectedAlertPresentException. Работают с ним через отдельный интерфейс:

driver.find_element(By.ID, "delete-account").click()

alert = wait.until(EC.alert_is_present())
print(alert.text)                     # текст вопроса — его тоже стоит проверять
assert "Удалить аккаунт?" in alert.text

alert.accept()                        # нажать OK
# alert.dismiss()                     # нажать "Отмена" (для confirm)

# prompt — единственный, который принимает ввод
driver.find_element(By.ID, "rename").click()
prompt = wait.until(EC.alert_is_present())
prompt.send_keys("Новое имя проекта")
prompt.accept()

Если алерты в тесте не нужны совсем (например, страница при уходе показывает «Покинуть сайт? Изменения не сохранятся»), можно попросить драйвер разбираться с ними самому:

from selenium import webdriver

options = webdriver.ChromeOptions()
options.unhandled_prompt_behavior = "accept"   # "dismiss", "ignore", "accept and notify"
driver = webdriver.Chrome(options=options)

Как это работает

Внутри протокола WebDriver у каждого контекста есть свой идентификатор, и почти каждая команда (Find Element, Execute Script, Take Screenshot) неявно адресуется текущему контексту. switch_to.frame и switch_to.window — это команды Switch To Frame и Switch To Window, которые просто меняют этот указатель на стороне драйвера. Отсюда все следствия: элементы «пропадают» не потому, что их нет, а потому что вы ищете их не в том мире. Handle остаётся валидным, пока вкладка жива, — закрыли вкладку, и указатель повис.

Ещё важная деталь: WebElement, найденный внутри фрейма, привязан к этому фрейму. Переключились в default_content() — и старая ссылка на элемент превращается в тыкву: StaleElementReferenceException. Правило простое: нашли и использовали элемент в том же контексте, не тащите ссылки через границу.

Алерты в спецификации живут отдельной веткой команд — Get Alert Text, Accept Alert, Dismiss Alert, Send Alert Text. Именно поэтому у них свой объект и свои методы, а не привычные click() и text.

Частые ошибки

  • Забыли default_content(). Следующий шаг теста ищет кнопку главной страницы, находясь внутри фрейма, и падает с NoSuchElementException. Самая частая ошибка раздела.
  • Ищут iframe внутри iframe без промежуточного переключения — во вложенные фреймы спускаются по одному уровню за раз.
  • driver.close() вместо driver.quit() в конце теста — закрывается вкладка, но процесс браузера и драйвера остаётся висеть. На CI это утечка памяти и зависшие джобы.
  • После close() не вернулись в главную вкладкуNoSuchWindowException на первой же следующей команде.
  • window_handles[-1] как «новая вкладка». Порядок не гарантирован; вычисляйте разницу со списком, который был до клика.
  • Пытаются кликнуть по алерту локатором. Нативное окно не в DOM — только switch_to.alert. И наоборот: если «модалка» ищется по CSS и находится, значит это обычный div, и switch_to.alert тут бросит NoAlertPresentException.

Итоги

  • Драйвер видит ровно один контекст: вкладку или фрейм. «Элемент не найден» при видимом элементе — почти всегда забытое переключение.
  • EC.frame_to_be_available_and_switch_to_it — надёжный вход во фрейм; выход — default_content() (наверх) или parent_frame() (на уровень выше).
  • Контекстный менеджер вокруг фрейма гарантирует возврат даже при падении теста внутри.
  • Новые вкладки: запомнить current_window_handle, дождаться number_of_windows_to_be, найти новый handle разницей, после close() — обязательно вернуться.
  • Нативные алерты — не DOM: switch_to.alert, text, accept(), dismiss(), send_keys() для prompt.
Проверьте себя
1. Тест заполнил поля внутри iframe платёжной формы, а затем упал с NoSuchElementException на кнопке «Оплатить», которая находится на самой странице. В чём причина?
AКнопка перекрыта другим элементом — нужен scrollIntoView
BДрайвер остался внутри фрейма: перед поиском кнопки не вызвали switch_to.default_content()
CКнопка ещё не отрендерилась — достаточно добавить неявное ожидание
DЛокатор кнопки устарел — нужно искать по XPath вместо ID
2. Что произойдёт, если после driver.close() новой вкладки сразу вызвать driver.find_element(...) без switch_to.window(main)?
AДрайвер автоматически вернётся в оставшуюся вкладку и найдёт элемент
BКоманда упадёт с NoSuchWindowException — указатель драйвера остался на закрытой вкладке
CВернётся пустой список элементов
DSelenium заново откроет закрытую вкладку