Фреймы, вкладки и алерты
Элемент виден на экране, а 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.