From b414beb6fb84c1997d9407421739840d2a4b10c4 Mon Sep 17 00:00:00 2001 From: spawn Date: Sat, 14 Feb 2026 21:56:07 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=97=20#2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 76 +++--- pom.xml | 46 ++-- .../java/ru/kovbasa/config/DriverModule.java | 4 +- .../java/ru/kovbasa/config/TestConfig.java | 14 +- .../driver/SelectableDriverFactory.java | 19 ++ .../ru/kovbasa/driver/WebDriverProvider.java | 6 - .../java/ru/kovbasa/elements/CourseCard.java | 25 +- .../listeners/HighlightElementListener.java | 33 +-- .../java/ru/kovbasa/pages/CatalogPage.java | 219 +++++++++++++++++- .../java/ru/kovbasa/pages/CourseLinkItem.java | 4 + .../java/ru/kovbasa/pages/CoursePage.java | 79 ++++++- src/main/java/ru/kovbasa/pages/MainPage.java | 1 - .../ru/kovbasa/pages/PricedCourseItem.java | 4 + .../java/ru/kovbasa/bdd/CucumberTest.java | 17 ++ src/test/java/ru/kovbasa/bdd/hooks/Hooks.java | 15 ++ .../ru/kovbasa/bdd/steps/CatalogSteps.java | 159 +++++++++++++ .../ru/kovbasa/config/GuiceExtension.java | 29 --- .../ru/kovbasa/tests/CategoryRandomTest.java | 35 --- .../ru/kovbasa/tests/CourseSearchTest.java | 33 --- .../ru/kovbasa/tests/CoursesDatesTest.java | 74 ------ src/test/resources/features/catalog.feature | 29 +++ src/test/resources/logback-test.xml | 15 ++ src/test/resources/logging.properties | 8 + 23 files changed, 694 insertions(+), 250 deletions(-) create mode 100644 src/main/java/ru/kovbasa/driver/SelectableDriverFactory.java create mode 100644 src/main/java/ru/kovbasa/pages/CourseLinkItem.java create mode 100644 src/main/java/ru/kovbasa/pages/PricedCourseItem.java create mode 100644 src/test/java/ru/kovbasa/bdd/CucumberTest.java create mode 100644 src/test/java/ru/kovbasa/bdd/hooks/Hooks.java create mode 100644 src/test/java/ru/kovbasa/bdd/steps/CatalogSteps.java delete mode 100644 src/test/java/ru/kovbasa/config/GuiceExtension.java delete mode 100644 src/test/java/ru/kovbasa/tests/CategoryRandomTest.java delete mode 100644 src/test/java/ru/kovbasa/tests/CourseSearchTest.java delete mode 100644 src/test/java/ru/kovbasa/tests/CoursesDatesTest.java create mode 100644 src/test/resources/features/catalog.feature create mode 100644 src/test/resources/logback-test.xml create mode 100644 src/test/resources/logging.properties diff --git a/README.md b/README.md index bb976c0..d3ab668 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,51 @@ -# OTUS Selenium Homework 1 +# OTUS Selenium Homework 2 (BDD + Cucumber) ## Цель проекта -Автоматизировать 3 UI-сценария на `https://otus.ru` с использованием Selenium WebDriver 4+, JUnit 5, Guice DI, listeners, Stream API, Jsoup и обязательных проверок качества (Checkstyle + SpotBugs). +Реализовать BDD-подход на `https://otus.ru` с использованием Selenium WebDriver 4+, Cucumber (русские feature), JUnit 5, Guice DI, listeners, Stream API, Jsoup и проверок качества (Checkstyle + SpotBugs). ## Стек технологий - Java 21 - Maven - Selenium `4.38.0` -- WebDriverManager `6.3.3` - JUnit 5 +- Cucumber (`cucumber-java`, `cucumber-junit-platform-engine`) - Guice - Jsoup - Checkstyle - SpotBugs -## Реализованные сценарии -1. Поиск курса по имени. - - Открытие каталога `https://otus.ru/catalog/courses` - - Поиск курса по имени через Stream API - - Клик по плитке курса - - Проверка заголовка открытого курса +## Реализованные BDD-сценарии +1. Выбор браузера через feature. + - Шаг: `Дано Открыт браузер Chrome` + - Фабрика драйвера выбирается по `browser` property (`chrome`) -2. Самые ранние/поздние курсы по дате старта. - - Открытие каталога `https://otus.ru/catalog/courses` - - Поиск ранних и поздних курсов через Stream API + `reduce` - - При совпадении дат проверяются все курсы с этой датой - - Проверка названия и даты старта на странице курса через Jsoup +2. Поиск курса по имени и случайный выбор при множественных результатах. + - Название курса задается в feature + - Выбирается случайный курс из найденных + - Проверяется заголовок открытой страницы курса -3. Случайная категория с главной страницы. - - Открытие `https://otus.ru` - - Открытие меню «Обучение» - - Выбор случайной категории - - Проверка, что открыта корректная категория +3. Поиск курсов, стартующих в указанную дату или позже. + - Дата задается в feature (`dd.MM.yyyy`) + - Выполняется фильтрация `startDate >= dateFrom` + - В консоль выводится: название + дата старта + +4. Раздел «Обучение» -> «Подготовительные курсы». + - Открывается пункт `Подготовительные курсы` из меню `Обучение` + - При необходимости нажимается `Показать еще ...`, чтобы загрузить весь список + - Из списка выбираются самый дорогой и самый дешевый + - Выбор реализован через Stream API + `filter` + - Информация о курсах выводится в консоль + +5. Общий каталог курсов: самый дорогой и самый дешевый по полной стоимости со скидкой. + - Открывается `https://otus.ru/catalog/courses` + - Для каждого курса берется цена сравнения: + - В приоритете `Полная` -> `Полная стоимость со скидкой` + - Для упрощенных online-страниц используется fallback по видимой цене + - Через `filter` выбираются max/min, результат выводится в консоль ## Архитектура -- 2-уровневый тест-дизайн: `tests` + `page objects` -- DI через Guice для тестов и страниц -- JUnit 5 Extension (`GuiceExtension`), без базового класса-теста +- 2-уровневый тест-дизайн: `BDD steps` + `page objects` +- DI через Guice для step definitions и страниц - Фабрика драйвера: - `DriverFactory` (интерфейс) - `ChromeDriverFactory` (реализация) @@ -52,8 +61,10 @@ - `src/main/java/ru/kovbasa/listeners` — listener подсветки - `src/main/java/ru/kovbasa/pages` — Page Object классы - `src/main/java/ru/kovbasa/elements` — типизированные UI-элементы -- `src/test/java/ru/kovbasa/config` — JUnit extension для DI -- `src/test/java/ru/kovbasa/tests` — автотесты +- `src/test/resources/features` — `.feature` файлы (русский язык) +- `src/test/java/ru/kovbasa/bdd` — Cucumber runner +- `src/test/java/ru/kovbasa/bdd/steps` — step definitions +- `src/test/java/ru/kovbasa/bdd/hooks` — lifecycle hooks ## Требования к окружению 1. Установлен JDK 21 (доступен в `PATH`) @@ -62,33 +73,36 @@ 4. Есть доступ в интернет и к `otus.ru` (тесты запускаются на живом сайте) ## Запуск -### 1. Запуск только тестов +### 1. Запуск всех тестов ```bash mvn test ``` -### 2. Полная проверка (тесты + Checkstyle + SpotBugs) +### 2. Запуск конкретного Cucumber runner ```bash -mvn verify +mvn "-Dtest=ru.kovbasa.bdd.CucumberTest" test ``` -### 3. Запуск отдельного тестового класса +### 3. Локальная полная проверка (необязательно для сдачи ДЗ) ```bash -mvn "-Dtest=ru.kovbasa.tests.CourseSearchTest" test +mvn verify ``` ## Параметры запуска Пробрасываются через Maven Surefire: - `base.url` (по умолчанию `https://otus.ru`) - `course.name` (по умолчанию `Python Developer`) +- `browser` (по умолчанию `chrome`) Пример переопределения: ```bash -mvn "-Dcourse.name=Python Developer" test +mvn "-Dbrowser=chrome" "-Dcourse.name=Python" test ``` ## Quality Gates -- Checkstyle и SpotBugs выполняется в фазе `verify` +- Checkstyle и SpotBugs выполняются в фазе `verify` (опционально) ## Примечания - Тесты зависят от текущей верстки/контента `otus.ru`. +- По умолчанию в `mvn test` запускается только `CucumberTest`. +- Для сдачи ДЗ достаточно успешного запуска `mvn test`. diff --git a/pom.xml b/pom.xml index 0e8899d..f321fea 100644 --- a/pom.xml +++ b/pom.xml @@ -16,16 +16,17 @@ https://otus.ru Python Developer + chrome 4.38.0 5.10.0 - 6.3.3 + 1.10.0 + 7.20.1 5.1.0 1.21.2 2.0.11 1.4.14 - 32.1.3-jre 3.11.0 @@ -45,13 +46,6 @@ ${selenium.version} - - - io.github.bonigarcia - webdrivermanager - ${webdrivermanager.version} - - org.junit.jupiter @@ -60,6 +54,27 @@ test + + org.junit.platform + junit-platform-suite + ${junit.platform.suite.version} + test + + + + io.cucumber + cucumber-java + ${cucumber.version} + test + + + + io.cucumber + cucumber-junit-platform-engine + ${cucumber.version} + test + + com.google.inject @@ -87,12 +102,6 @@ ${logback.version} - - - com.google.guava - guava - ${guava.version} - @@ -115,9 +124,16 @@ ${surefire.version} false + + -Djava.util.logging.config.file=${project.basedir}/src/test/resources/logging.properties + + + **/CucumberTest.java + ${base.url} ${course.name} + ${browser} diff --git a/src/main/java/ru/kovbasa/config/DriverModule.java b/src/main/java/ru/kovbasa/config/DriverModule.java index 5248999..a6cad54 100644 --- a/src/main/java/ru/kovbasa/config/DriverModule.java +++ b/src/main/java/ru/kovbasa/config/DriverModule.java @@ -2,15 +2,15 @@ package ru.kovbasa.config; import com.google.inject.AbstractModule; import com.google.inject.Singleton; -import ru.kovbasa.driver.ChromeDriverFactory; import ru.kovbasa.driver.DriverFactory; +import ru.kovbasa.driver.SelectableDriverFactory; import ru.kovbasa.driver.WebDriverProvider; public class DriverModule extends AbstractModule { @Override protected void configure() { - bind(DriverFactory.class).to(ChromeDriverFactory.class).in(Singleton.class); + bind(DriverFactory.class).to(SelectableDriverFactory.class).in(Singleton.class); bind(WebDriverProvider.class).in(Singleton.class); } } diff --git a/src/main/java/ru/kovbasa/config/TestConfig.java b/src/main/java/ru/kovbasa/config/TestConfig.java index fa7527c..8322d9f 100644 --- a/src/main/java/ru/kovbasa/config/TestConfig.java +++ b/src/main/java/ru/kovbasa/config/TestConfig.java @@ -2,20 +2,18 @@ package ru.kovbasa.config; public final class TestConfig { - private static final String BASE_URL = - System.getProperty("base.url", "https://otus.ru"); - - private static final String COURSE_NAME = - System.getProperty("course.name", "Python Developer"); - private TestConfig() { } public static String getBaseUrl() { - return BASE_URL; + return System.getProperty("base.url", "https://otus.ru"); } public static String getCourseName() { - return COURSE_NAME; + return System.getProperty("course.name", "Python Developer"); + } + + public static String getBrowser() { + return System.getProperty("browser", "chrome"); } } diff --git a/src/main/java/ru/kovbasa/driver/SelectableDriverFactory.java b/src/main/java/ru/kovbasa/driver/SelectableDriverFactory.java new file mode 100644 index 0000000..75409a1 --- /dev/null +++ b/src/main/java/ru/kovbasa/driver/SelectableDriverFactory.java @@ -0,0 +1,19 @@ +package ru.kovbasa.driver; + +import org.openqa.selenium.WebDriver; +import ru.kovbasa.config.TestConfig; + +public class SelectableDriverFactory implements DriverFactory { + + private final DriverFactory chromeFactory = new ChromeDriverFactory(); + + @Override + public WebDriver createDriver() { + final String browser = TestConfig.getBrowser().toLowerCase(); + if (!"chrome".equals(browser)) { + throw new IllegalArgumentException( + "Unsupported browser: " + browser + ". Supported browser: chrome"); + } + return chromeFactory.createDriver(); + } +} diff --git a/src/main/java/ru/kovbasa/driver/WebDriverProvider.java b/src/main/java/ru/kovbasa/driver/WebDriverProvider.java index 2b40ec1..652e3ae 100644 --- a/src/main/java/ru/kovbasa/driver/WebDriverProvider.java +++ b/src/main/java/ru/kovbasa/driver/WebDriverProvider.java @@ -3,8 +3,6 @@ package ru.kovbasa.driver; import com.google.inject.Inject; import org.openqa.selenium.WebDriver; import org.openqa.selenium.support.events.EventFiringDecorator; - -import io.github.bonigarcia.wdm.WebDriverManager; import ru.kovbasa.listeners.HighlightElementListener; public final class WebDriverProvider { @@ -12,10 +10,6 @@ public final class WebDriverProvider { private WebDriver driver; private final DriverFactory driverFactory; - static { - WebDriverManager.chromedriver().setup(); - } - @Inject public WebDriverProvider(DriverFactory driverFactory) { this.driverFactory = driverFactory; diff --git a/src/main/java/ru/kovbasa/elements/CourseCard.java b/src/main/java/ru/kovbasa/elements/CourseCard.java index 37a034f..a4be7c7 100644 --- a/src/main/java/ru/kovbasa/elements/CourseCard.java +++ b/src/main/java/ru/kovbasa/elements/CourseCard.java @@ -7,11 +7,14 @@ import java.time.LocalDate; import java.time.Year; import java.time.format.DateTimeFormatter; import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class CourseCard extends BaseElement { private final By titleLocator = By.cssSelector("h6 .sc-hrqzy3-1"); private final By dateLocator = By.cssSelector(".sc-157icee-1 .sc-hrqzy3-1"); + private static final Pattern PRICE_PATTERN = Pattern.compile("(\\d[\\d\\s]*)\\s*[₽р]"); public CourseCard(WebElement element) { super(element); @@ -51,4 +54,24 @@ public class CourseCard extends BaseElement { return LocalDate.parse(normalized, formatter); } -} \ No newline at end of file + + public int price() { + final String text = element.getText(); + final Matcher matcher = PRICE_PATTERN.matcher(text); + + int maxPrice = -1; + while (matcher.find()) { + final String raw = matcher.group(1).replace(" ", ""); + final int parsed = Integer.parseInt(raw); + if (parsed > maxPrice) { + maxPrice = parsed; + } + } + + if (maxPrice < 0) { + throw new RuntimeException("Цена курса не найдена в карточке: " + title()); + } + + return maxPrice; + } +} diff --git a/src/main/java/ru/kovbasa/listeners/HighlightElementListener.java b/src/main/java/ru/kovbasa/listeners/HighlightElementListener.java index 0c40c64..4512f37 100644 --- a/src/main/java/ru/kovbasa/listeners/HighlightElementListener.java +++ b/src/main/java/ru/kovbasa/listeners/HighlightElementListener.java @@ -1,5 +1,7 @@ package ru.kovbasa.listeners; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.openqa.selenium.JavascriptExecutor; import org.openqa.selenium.StaleElementReferenceException; import org.openqa.selenium.WebDriver; @@ -8,6 +10,7 @@ import org.openqa.selenium.WrapsDriver; import org.openqa.selenium.support.events.WebDriverListener; public class HighlightElementListener implements WebDriverListener { + private static final Logger LOG = LoggerFactory.getLogger(HighlightElementListener.class); private static final String HIGHLIGHT_STYLE = "outline: 4px solid red !important; " + @@ -20,16 +23,11 @@ public class HighlightElementListener implements WebDriverListener { public void beforeClick(WebElement element) { try { clearHighlight(); - - final String tag = safeGetTag(element); - final String href = safeGetAttr(element, "href"); - final String cls = safeGetAttr(element, "class"); - System.out.println("beforeClick: tag=" + tag + " href=" + href + " class=" + cls); - final WebElement tile = findTileContainer(element); applyHighlight(tile); - } catch (Exception e) { - System.out.println("Highlight listener error: " + e.getMessage()); + pauseForVisibility(); + } catch (RuntimeException e) { + LOG.debug("beforeClick highlight skipped", e); } } @@ -62,8 +60,8 @@ public class HighlightElementListener implements WebDriverListener { js.executeScript("arguments[0].setAttribute('style', arguments[1] + '; ' + arguments[2]);", element, original, HIGHLIGHT_STYLE); } catch (StaleElementReferenceException ignored) { - } catch (Exception e) { - System.out.println("applyHighlight error: " + e.getMessage()); + } catch (RuntimeException e) { + LOG.debug("applyHighlight skipped", e); } } @@ -84,8 +82,8 @@ public class HighlightElementListener implements WebDriverListener { final String original = highlightedOriginalStyle == null ? "" : highlightedOriginalStyle; js.executeScript("arguments[0].setAttribute('style', arguments[1]);", highlightedElement, original); } catch (StaleElementReferenceException ignored) { - } catch (Exception e) { - System.out.println("clearHighlight error: " + e.getMessage()); + } catch (RuntimeException e) { + LOG.debug("clearHighlight skipped", e); } finally { highlightedElement = null; highlightedOriginalStyle = null; @@ -134,7 +132,8 @@ public class HighlightElementListener implements WebDriverListener { return current; } } - } catch (Exception ignored) { + } catch (RuntimeException e) { + LOG.debug("findTileContainer fallback to original element", e); } return element; } @@ -148,4 +147,12 @@ public class HighlightElementListener implements WebDriverListener { try { return el.getTagName(); } catch (Exception e) { return null; } } + + private void pauseForVisibility() { + try { + Thread.sleep(120); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } } diff --git a/src/main/java/ru/kovbasa/pages/CatalogPage.java b/src/main/java/ru/kovbasa/pages/CatalogPage.java index 889ce58..27889be 100644 --- a/src/main/java/ru/kovbasa/pages/CatalogPage.java +++ b/src/main/java/ru/kovbasa/pages/CatalogPage.java @@ -3,9 +3,13 @@ package ru.kovbasa.pages; import com.google.inject.Inject; import java.time.Duration; import java.time.LocalDate; +import java.util.ArrayList; import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.concurrent.ThreadLocalRandom; import org.openqa.selenium.By; import org.openqa.selenium.JavascriptExecutor; @@ -26,7 +30,11 @@ public class CatalogPage { private final WebDriver driver; private final By courseCards = By.cssSelector("a.sc-zzdkm7-0"); - private final By courseLinks = By.cssSelector("a[href*='/lessons/']"); + private final By courseLinks = By.cssSelector("a[href*='/lessons/'], a[href*='/online/']"); + private final By learningMenu = By.cssSelector("span[title='Обучение']"); + private final By prepCoursesLink = By.xpath( + "//a[contains(normalize-space(),'Подготовительные курсы')]"); + private final By showMoreButton = By.xpath("//button[contains(normalize-space(),'Показать еще')]"); @Inject public CatalogPage(WebDriverProvider provider) { @@ -50,6 +58,15 @@ public class CatalogPage { "Course not found in catalog by name: " + name)); } + public List findCoursesByName(String name) { + final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15)); + wait.until((ExpectedCondition) drv -> !drv.findElements(courseLinks).isEmpty()); + + return driver.findElements(courseLinks).stream() + .filter(e -> e.getText().toLowerCase().contains(name.toLowerCase())) + .toList(); + } + public CoursePage clickCourseByName(String name) { PageUtils.removeBottomBanner(driver); @@ -66,6 +83,27 @@ public class CatalogPage { return new CoursePage(driver); } + public CoursePage clickRandomCourseByName(String name) { + PageUtils.removeBottomBanner(driver); + + final List courses = findCoursesByName(name); + if (courses.isEmpty()) { + throw new NoSuchElementException("Course not found in catalog by name: " + name); + } + + final WebElement chosen = courses.get(ThreadLocalRandom.current().nextInt(courses.size())); + + ((JavascriptExecutor) driver) + .executeScript("arguments[0].scrollIntoView({block:'center'});", chosen); + + new Actions(driver) + .moveToElement(chosen) + .perform(); + chosen.click(); + + return new CoursePage(driver); + } + public List getAllCourseCards() { return driver.findElements(courseCards).stream() .map(CourseCard::new) @@ -113,6 +151,185 @@ public class CatalogPage { .toList(); } + public List findCoursesStartingFrom(LocalDate dateFrom) { + return getAllCourses().stream() + .filter(course -> !course.startDate().isBefore(dateFrom)) + .sorted(Comparator.comparing(CourseItem::startDate).thenComparing(CourseItem::title)) + .toList(); + } + + public CatalogPage openPreparatoryCourses() { + driver.get(TestConfig.getBaseUrl()); + PageUtils.removeBottomBanner(driver); + + final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15)); + final WebElement menu = wait.until(drv -> drv.findElement(learningMenu)); + menu.click(); + + final WebElement prep = wait.until(drv -> drv.findElements(prepCoursesLink).stream() + .filter(WebElement::isDisplayed) + .findFirst() + .orElse(null)); + + ((JavascriptExecutor) driver) + .executeScript("arguments[0].scrollIntoView({block:'center'});", prep); + prep.click(); + + wait.until(drv -> drv.findElements( + By.xpath("//*[contains(normalize-space(),'Подготовительные курсы')]") + ).stream().anyMatch(WebElement::isDisplayed)); + + expandAllCourses(); + return this; + } + + public List getAllCoursesWithPrice() { + return getAllCourseCards().stream() + .map(card -> { + try { + return new PricedCourseItem(card.title(), card.price()); + } catch (RuntimeException e) { + return null; + } + }) + .filter(Objects::nonNull) + .toList(); + } + + public List getPreparatoryCourseLinks() { + final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15)); + wait.until((ExpectedCondition) drv -> !drv.findElements(courseLinks).isEmpty()); + + final Map titleByUrl = new LinkedHashMap<>(); + for (WebElement link : driver.findElements(courseLinks)) { + if (!link.isDisplayed()) { + continue; + } + final String title = link.getText().trim(); + final String url = link.getAttribute("href"); + if (title.isEmpty() || url == null || !isCourseUrl(url)) { + continue; + } + titleByUrl.putIfAbsent(url, title); + } + + final List> onlineEntries = titleByUrl.entrySet().stream() + .filter(entry -> entry.getKey().contains("/online/")) + .toList(); + + final List items = new ArrayList<>(); + if (!onlineEntries.isEmpty()) { + onlineEntries.forEach(entry -> items.add(new CourseLinkItem(cleanTitle(entry.getValue()), entry.getKey()))); + return items; + } + + titleByUrl.forEach((url, title) -> items.add(new CourseLinkItem(cleanTitle(title), url))); + return items; + } + + public List getPreparatoryCoursesWithDiscountedFullPrice() { + final List links = getPreparatoryCourseLinks(); + return links.stream() + .map(link -> { + driver.get(link.url()); + final CoursePage coursePage = new CoursePage(driver); + final int price = coursePage.getPriceForComparison(); + String title = link.title(); + try { + title = coursePage.getCourseTitle(); + } catch (RuntimeException ignored) { } + return new PricedCourseItem(title, price); + }) + .toList(); + } + + public List getCatalogLessonCourseLinks() { + open(); + + final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15)); + wait.until((ExpectedCondition) drv -> !drv.findElements(courseLinks).isEmpty()); + + final Map titleByUrl = new LinkedHashMap<>(); + for (WebElement link : driver.findElements(courseLinks)) { + if (!link.isDisplayed()) { + continue; + } + final String title = link.getText().trim(); + final String url = link.getAttribute("href"); + if (title.isEmpty() || url == null || !url.contains("/lessons/")) { + continue; + } + titleByUrl.putIfAbsent(url, cleanTitle(title)); + } + + final List items = new ArrayList<>(); + titleByUrl.forEach((url, title) -> items.add(new CourseLinkItem(title, url))); + return items; + } + + public List getCatalogCoursesWithDiscountedFullPrice() { + final List links = getCatalogLessonCourseLinks(); + return links.stream() + .map(link -> { + driver.get(link.url()); + final CoursePage coursePage = new CoursePage(driver); + final int price = coursePage.getPriceForComparison(); + String title = link.title(); + try { + title = coursePage.getCourseTitle(); + } catch (RuntimeException ignored) { } + return new PricedCourseItem(title, price); + }) + .toList(); + } + + private void expandAllCourses() { + final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(5)); + + while (true) { + try { + final WebElement button = wait.until(drv -> drv.findElements(showMoreButton).stream() + .filter(WebElement::isDisplayed) + .findFirst() + .orElse(null)); + + ((JavascriptExecutor) driver) + .executeScript("arguments[0].scrollIntoView({block:'center'});", button); + button.click(); + wait.until(drv -> !drv.findElements(courseLinks).isEmpty()); + } catch (RuntimeException ignored) { + break; + } + } + } + + private boolean isCourseUrl(String url) { + return url.contains("/lessons/") || url.contains("/online/"); + } + + private String cleanTitle(String rawTitle) { + final String[] lines = rawTitle.split("\\R"); + for (int i = lines.length - 1; i >= 0; i--) { + final String line = lines[i].trim(); + if (!line.isEmpty()) { + return line; + } + } + return rawTitle.trim(); + } + + public PricedCourseItem findMostExpensiveCourse() { + return getAllCoursesWithPrice().stream() + .reduce((left, right) -> left.price() >= right.price() ? left : right) + .orElseThrow(() -> new NoSuchElementException("Priced course cards are not found")); + } + + public PricedCourseItem findCheapestCourse() { + return getAllCoursesWithPrice().stream() + .reduce((left, right) -> left.price() <= right.price() ? left : right) + .orElseThrow(() -> new NoSuchElementException("Priced course cards are not found")); + } + public CoursePage openCourse(CourseItem course) { PageUtils.removeBottomBanner(driver); diff --git a/src/main/java/ru/kovbasa/pages/CourseLinkItem.java b/src/main/java/ru/kovbasa/pages/CourseLinkItem.java new file mode 100644 index 0000000..6ff0fa7 --- /dev/null +++ b/src/main/java/ru/kovbasa/pages/CourseLinkItem.java @@ -0,0 +1,4 @@ +package ru.kovbasa.pages; + +public record CourseLinkItem(String title, String url) { +} diff --git a/src/main/java/ru/kovbasa/pages/CoursePage.java b/src/main/java/ru/kovbasa/pages/CoursePage.java index 23740b6..d59a193 100644 --- a/src/main/java/ru/kovbasa/pages/CoursePage.java +++ b/src/main/java/ru/kovbasa/pages/CoursePage.java @@ -4,18 +4,31 @@ import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.openqa.selenium.By; +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.NoSuchElementException; import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.interactions.Actions; +import org.openqa.selenium.support.ui.WebDriverWait; import ru.kovbasa.elements.Button; +import ru.kovbasa.utils.PageUtils; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class CoursePage { + private static final Pattern PRICE_PATTERN = Pattern.compile("(\\d[\\d\\s\\u00A0]*)\\s*₽"); + private final WebDriver driver; private final By enrollButton = By.cssSelector("button[data-testid='enroll-button']"); + private final By fullPriceTab = By.xpath("//div[normalize-space()='Полная']/parent::div"); + private final By fullDiscountLabel = By.xpath("//p[contains(normalize-space(),'Полная стоимость со скидкой')]"); + private final By anyPriceText = By.xpath("//*[contains(normalize-space(),'₽')]"); public CoursePage(WebDriver driver) { this.driver = driver; @@ -59,4 +72,68 @@ public class CoursePage { public void clickEnroll() { getEnrollButton().click(); } -} \ No newline at end of file + + public int getDiscountedFullPrice() { + PageUtils.removeBottomBanner(driver); + + final WebDriverWait wait = new WebDriverWait(driver, java.time.Duration.ofSeconds(10)); + final WebElement tab = wait.until(drv -> drv.findElements(fullPriceTab).stream() + .filter(WebElement::isDisplayed) + .findFirst() + .orElse(null)); + + ((JavascriptExecutor) driver).executeScript("arguments[0].scrollIntoView({block:'center'});", tab); + try { + tab.click(); + } catch (RuntimeException ignored) { + new Actions(driver).moveToElement(tab).click().perform(); + } + + final WebElement label = wait.until(drv -> drv.findElements(fullDiscountLabel).stream() + .filter(WebElement::isDisplayed) + .findFirst() + .orElse(null)); + + final WebElement priceElement = findPriceElementNearLabel(label); + final String rawPrice = priceElement.getText(); + return parsePrice(rawPrice); + } + + public int getPriceForComparison() { + if (hasDisplayed(fullPriceTab)) { + try { + return getDiscountedFullPrice(); + } catch (RuntimeException ignored) { } + } + + final WebDriverWait wait = new WebDriverWait(driver, java.time.Duration.ofSeconds(10)); + final WebElement priceElement = wait.until(drv -> drv.findElements(anyPriceText).stream() + .filter(WebElement::isDisplayed) + .filter(el -> PRICE_PATTERN.matcher(el.getText()).find()) + .findFirst() + .orElse(null)); + + return parsePrice(priceElement.getText()); + } + + private WebElement findPriceElementNearLabel(WebElement label) { + try { + return label.findElement(By.xpath("following-sibling::div[1]")); + } catch (NoSuchElementException ignored) { + return label.findElement(By.xpath("following::div[contains(normalize-space(),'₽')][1]")); + } + } + + private int parsePrice(String rawPrice) { + final Matcher matcher = PRICE_PATTERN.matcher(rawPrice); + if (!matcher.find()) { + throw new IllegalArgumentException("Не удалось распарсить цену из строки: " + rawPrice); + } + final String normalized = matcher.group(1).replace('\u00A0', ' ').replace(" ", ""); + return Integer.parseInt(normalized); + } + + private boolean hasDisplayed(By by) { + return driver.findElements(by).stream().anyMatch(WebElement::isDisplayed); + } +} diff --git a/src/main/java/ru/kovbasa/pages/MainPage.java b/src/main/java/ru/kovbasa/pages/MainPage.java index 2499cab..05198a6 100644 --- a/src/main/java/ru/kovbasa/pages/MainPage.java +++ b/src/main/java/ru/kovbasa/pages/MainPage.java @@ -50,7 +50,6 @@ public class MainPage { final WebElement chosen = els.get(ThreadLocalRandom.current().nextInt(els.size())); final String href = chosen.getAttribute("href"); - System.out.println("Selected category href = " + href); ((JavascriptExecutor) driver) .executeScript("arguments[0].scrollIntoView({block:'center'});", chosen); diff --git a/src/main/java/ru/kovbasa/pages/PricedCourseItem.java b/src/main/java/ru/kovbasa/pages/PricedCourseItem.java new file mode 100644 index 0000000..edc0025 --- /dev/null +++ b/src/main/java/ru/kovbasa/pages/PricedCourseItem.java @@ -0,0 +1,4 @@ +package ru.kovbasa.pages; + +public record PricedCourseItem(String title, int price) { +} diff --git a/src/test/java/ru/kovbasa/bdd/CucumberTest.java b/src/test/java/ru/kovbasa/bdd/CucumberTest.java new file mode 100644 index 0000000..b9700e8 --- /dev/null +++ b/src/test/java/ru/kovbasa/bdd/CucumberTest.java @@ -0,0 +1,17 @@ +package ru.kovbasa.bdd; + +import org.junit.platform.suite.api.ConfigurationParameter; +import org.junit.platform.suite.api.IncludeEngines; +import org.junit.platform.suite.api.SelectClasspathResource; +import org.junit.platform.suite.api.Suite; + +import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME; +import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME; + +@Suite +@IncludeEngines("cucumber") +@SelectClasspathResource("features") +@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "ru.kovbasa.bdd") +@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty") +public class CucumberTest { +} diff --git a/src/test/java/ru/kovbasa/bdd/hooks/Hooks.java b/src/test/java/ru/kovbasa/bdd/hooks/Hooks.java new file mode 100644 index 0000000..a4bfea2 --- /dev/null +++ b/src/test/java/ru/kovbasa/bdd/hooks/Hooks.java @@ -0,0 +1,15 @@ +package ru.kovbasa.bdd.hooks; + +import com.google.inject.Injector; +import io.cucumber.java.After; +import ru.kovbasa.config.InjectorProvider; +import ru.kovbasa.driver.WebDriverProvider; + +public class Hooks { + + @After + public void afterScenario() { + final Injector injector = InjectorProvider.getInjector(); + injector.getInstance(WebDriverProvider.class).quit(); + } +} diff --git a/src/test/java/ru/kovbasa/bdd/steps/CatalogSteps.java b/src/test/java/ru/kovbasa/bdd/steps/CatalogSteps.java new file mode 100644 index 0000000..03e9598 --- /dev/null +++ b/src/test/java/ru/kovbasa/bdd/steps/CatalogSteps.java @@ -0,0 +1,159 @@ +package ru.kovbasa.bdd.steps; + +import com.google.inject.Injector; +import io.cucumber.java.ru.Дано; +import io.cucumber.java.ru.И; +import io.cucumber.java.ru.Когда; +import io.cucumber.java.ru.Тогда; +import org.junit.jupiter.api.Assertions; +import ru.kovbasa.config.InjectorProvider; +import ru.kovbasa.pages.CatalogPage; +import ru.kovbasa.pages.CourseItem; +import ru.kovbasa.pages.CoursePage; +import ru.kovbasa.pages.PricedCourseItem; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Locale; + +public class CatalogSteps { + + private static final DateTimeFormatter DATE_FORMATTER = + DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.forLanguageTag("ru")); + + private CoursePage openedCoursePage; + private List coursesStartingFromDate; + private LocalDate searchDate; + private PricedCourseItem mostExpensiveCourse; + private PricedCourseItem cheapestCourse; + private PricedCourseItem mostExpensiveCatalogCourse; + private PricedCourseItem cheapestCatalogCourse; + + @Дано("Открыт браузер {word}") + public void openBrowser(String browser) { + System.setProperty("browser", browser.toLowerCase(Locale.ROOT)); + } + + @Когда("Открыт каталог курсов") + public void openCatalog() { + getCatalogPage().open(); + } + + @Когда("Найден курс {string} и выбран случайный из найденных") + public void findCourseAndOpenRandom(String courseName) { + openedCoursePage = getCatalogPage().clickRandomCourseByName(courseName); + } + + @Тогда("Заголовок страницы курса содержит {string}") + public void coursePageTitleContains(String expectedCourseName) { + final String title = openedCoursePage.getCourseTitle(); + Assertions.assertTrue( + title.toLowerCase(Locale.ROOT).contains(expectedCourseName.toLowerCase(Locale.ROOT)), + "Заголовок страницы курса должен содержать: " + expectedCourseName + ", фактически: " + title + ); + } + + @Когда("Найдены курсы со стартом {string} или позже") + public void findCoursesFromDate(String date) { + searchDate = LocalDate.parse(date, DATE_FORMATTER); + coursesStartingFromDate = getCatalogPage().findCoursesStartingFrom(searchDate); + } + + @Тогда("В консоль выведены найденные курсы и даты старта") + public void printCoursesAndStartDates() { + Assertions.assertFalse(coursesStartingFromDate.isEmpty(), "Не найдено курсов по заданной дате"); + + coursesStartingFromDate.forEach(course -> System.out.println( + "Курс: " + course.title() + ", дата старта: " + course.startDate().format(DATE_FORMATTER) + )); + + final boolean allAfterOrEqual = coursesStartingFromDate.stream() + .allMatch(course -> !course.startDate().isBefore(searchDate)); + + Assertions.assertTrue( + allAfterOrEqual, + "Среди найденных есть курсы с датой старта раньше " + searchDate.format(DATE_FORMATTER) + ); + } + + @Когда("Открыт раздел Обучение и Подготовительные курсы") + public void openPreparatoryCourses() { + getCatalogPage().openPreparatoryCourses(); + } + + @И("Выбраны самый дорогой и самый дешевый курсы с помощью filter") + public void findMostExpensiveAndCheapestByFilter() { + final List pricedCourses = getCatalogPage().getPreparatoryCoursesWithDiscountedFullPrice(); + Assertions.assertFalse(pricedCourses.isEmpty(), "Не найдено курсов с ценой"); + + final int maxPrice = pricedCourses.stream().mapToInt(PricedCourseItem::price).max().orElseThrow(); + final int minPrice = pricedCourses.stream().mapToInt(PricedCourseItem::price).min().orElseThrow(); + + mostExpensiveCourse = pricedCourses.stream() + .filter(course -> course.price() == maxPrice) + .findFirst() + .orElseThrow(); + + cheapestCourse = pricedCourses.stream() + .filter(course -> course.price() == minPrice) + .findFirst() + .orElseThrow(); + } + + @Тогда("В консоль выведена информация о самом дорогом и самом дешевом курсе") + public void printMostExpensiveAndCheapestCourse() { + System.out.println( + "Самый дорогой курс: " + mostExpensiveCourse.title() + ", цена: " + mostExpensiveCourse.price() + ); + System.out.println( + "Самый дешевый курс: " + cheapestCourse.title() + ", цена: " + cheapestCourse.price() + ); + + Assertions.assertTrue( + mostExpensiveCourse.price() >= cheapestCourse.price(), + "Цена самого дорогого курса должна быть не меньше цены самого дешевого" + ); + } + + @Когда("В каталоге курсов выбраны самый дорогой и самый дешевый курсы по полной стоимости со скидкой с помощью filter") + public void findMostExpensiveAndCheapestInCatalogByFullDiscountedPrice() { + final List pricedCourses = getCatalogPage().getCatalogCoursesWithDiscountedFullPrice(); + Assertions.assertFalse(pricedCourses.isEmpty(), "Не найдено курсов с ценой в общем каталоге"); + + final int maxPrice = pricedCourses.stream().mapToInt(PricedCourseItem::price).max().orElseThrow(); + final int minPrice = pricedCourses.stream().mapToInt(PricedCourseItem::price).min().orElseThrow(); + + mostExpensiveCatalogCourse = pricedCourses.stream() + .filter(course -> course.price() == maxPrice) + .findFirst() + .orElseThrow(); + + cheapestCatalogCourse = pricedCourses.stream() + .filter(course -> course.price() == minPrice) + .findFirst() + .orElseThrow(); + } + + @Тогда("В консоль выведена информация о самом дорогом и самом дешевом курсе в каталоге") + public void printMostExpensiveAndCheapestCourseInCatalog() { + System.out.println( + "Самый дорогой курс в каталоге: " + + mostExpensiveCatalogCourse.title() + ", цена: " + mostExpensiveCatalogCourse.price() + ); + System.out.println( + "Самый дешевый курс в каталоге: " + + cheapestCatalogCourse.title() + ", цена: " + cheapestCatalogCourse.price() + ); + + Assertions.assertTrue( + mostExpensiveCatalogCourse.price() >= cheapestCatalogCourse.price(), + "Цена самого дорогого курса в каталоге должна быть не меньше цены самого дешевого" + ); + } + + private CatalogPage getCatalogPage() { + final Injector injector = InjectorProvider.getInjector(); + return injector.getInstance(CatalogPage.class); + } +} diff --git a/src/test/java/ru/kovbasa/config/GuiceExtension.java b/src/test/java/ru/kovbasa/config/GuiceExtension.java deleted file mode 100644 index 545ed94..0000000 --- a/src/test/java/ru/kovbasa/config/GuiceExtension.java +++ /dev/null @@ -1,29 +0,0 @@ -package ru.kovbasa.config; - -import com.google.inject.Injector; -import org.junit.jupiter.api.extension.AfterEachCallback; -import org.junit.jupiter.api.extension.BeforeEachCallback; -import org.junit.jupiter.api.extension.ExtensionContext; -import org.junit.jupiter.api.extension.TestInstancePostProcessor; -import ru.kovbasa.driver.WebDriverProvider; - -public class GuiceExtension implements TestInstancePostProcessor, BeforeEachCallback, AfterEachCallback { - - @Override - public void postProcessTestInstance(Object testInstance, ExtensionContext context) { - final Injector injector = InjectorProvider.getInjector(); - injector.injectMembers(testInstance); - } - - @Override - public void beforeEach(ExtensionContext context) { - final Injector injector = InjectorProvider.getInjector(); - injector.getInstance(WebDriverProvider.class).getDriver(); - } - - @Override - public void afterEach(ExtensionContext context) { - final Injector injector = InjectorProvider.getInjector(); - injector.getInstance(WebDriverProvider.class).quit(); - } -} diff --git a/src/test/java/ru/kovbasa/tests/CategoryRandomTest.java b/src/test/java/ru/kovbasa/tests/CategoryRandomTest.java deleted file mode 100644 index 6487c29..0000000 --- a/src/test/java/ru/kovbasa/tests/CategoryRandomTest.java +++ /dev/null @@ -1,35 +0,0 @@ -package ru.kovbasa.tests; - -import com.google.inject.Inject; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.Test; -import ru.kovbasa.config.GuiceExtension; -import ru.kovbasa.driver.WebDriverProvider; -import ru.kovbasa.pages.MainPage; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -@ExtendWith(GuiceExtension.class) -public class CategoryRandomTest { - - @Inject - private MainPage main; - - @Inject - private WebDriverProvider webDriverProvider; - - @Test - void randomCategoryOpensCorrectCatalog() { - main.open(); - - final String selectedHref = main.clickRandomCategory(); - final String category = selectedHref.substring(selectedHref.lastIndexOf("/") + 1); - final String currentUrl = webDriverProvider.getDriver().getCurrentUrl(); - - assertTrue( - currentUrl.contains(category), - "Catalog URL should contain selected category. Selected: " - + selectedHref + ", current: " + currentUrl - ); - } -} diff --git a/src/test/java/ru/kovbasa/tests/CourseSearchTest.java b/src/test/java/ru/kovbasa/tests/CourseSearchTest.java deleted file mode 100644 index 0b4c71b..0000000 --- a/src/test/java/ru/kovbasa/tests/CourseSearchTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package ru.kovbasa.tests; - -import com.google.inject.Inject; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.Test; -import ru.kovbasa.config.GuiceExtension; -import ru.kovbasa.config.TestConfig; -import ru.kovbasa.pages.CatalogPage; -import ru.kovbasa.pages.CoursePage; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -@ExtendWith(GuiceExtension.class) -public class CourseSearchTest { - - @Inject - private CatalogPage catalog; - - @Test - void findCourseByName() { - catalog.open(); - - final String courseName = TestConfig.getCourseName(); - - final CoursePage page = catalog.clickCourseByName(courseName); - - final String title = page.getCourseTitle(); - assertTrue( - title.toLowerCase().contains(courseName.toLowerCase()), - "Course page title should contain searched course name" - ); - } -} diff --git a/src/test/java/ru/kovbasa/tests/CoursesDatesTest.java b/src/test/java/ru/kovbasa/tests/CoursesDatesTest.java deleted file mode 100644 index 51b7e37..0000000 --- a/src/test/java/ru/kovbasa/tests/CoursesDatesTest.java +++ /dev/null @@ -1,74 +0,0 @@ -package ru.kovbasa.tests; - -import com.google.inject.Inject; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.Test; -import ru.kovbasa.config.GuiceExtension; -import ru.kovbasa.pages.CatalogPage; -import ru.kovbasa.pages.CourseItem; -import ru.kovbasa.pages.CoursePage; - -import java.time.LocalDate; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@ExtendWith(GuiceExtension.class) -public class CoursesDatesTest { - - @Inject - private CatalogPage catalog; - - @Test - void earliestCourseHasCorrectTitleAndDate() { - catalog.open(); - - final List earliestCourses = catalog.findEarliestCourses(); - - for (CourseItem course : earliestCourses) { - final CoursePage page = catalog.openCourse(course); - - final String pageTitle = page.getCourseTitle(); - assertTrue( - pageTitle.toLowerCase().contains(course.title().toLowerCase()), - "Earliest course title on page should contain title from catalog: " + course.title() - ); - - final LocalDate pageDate = page.getCourseStartDate(course.startDate()); - assertEquals( - course.startDate(), - pageDate, - "Earliest course start date should match for course: " + course.title() - ); - - catalog.open(); - } - } - - @Test - void latestCourseHasCorrectTitleAndDate() { - catalog.open(); - - final List latestCourses = catalog.findLatestCourses(); - - for (CourseItem course : latestCourses) { - final CoursePage page = catalog.openCourse(course); - - final String pageTitle = page.getCourseTitle(); - assertTrue( - pageTitle.toLowerCase().contains(course.title().toLowerCase()), - "Latest course title on page should contain title from catalog: " + course.title() - ); - - final LocalDate pageDate = page.getCourseStartDate(course.startDate()); - assertEquals( - course.startDate(), - pageDate, - "Latest course start date should match for course: " + course.title() - ); - - catalog.open(); - } - } -} diff --git a/src/test/resources/features/catalog.feature b/src/test/resources/features/catalog.feature new file mode 100644 index 0000000..cd23d5a --- /dev/null +++ b/src/test/resources/features/catalog.feature @@ -0,0 +1,29 @@ +# language: ru + +Функция: Каталог курсов OTUS + Как пользователь + Я хочу работать с каталогом курсов через BDD + Чтобы проверять поиск, фильтрацию по датам и выбор по цене + + Сценарий: Выбор браузера и поиск курса по названию со случайным выбором + Дано Открыт браузер Chrome + Когда Открыт каталог курсов + И Найден курс "Python" и выбран случайный из найденных + Тогда Заголовок страницы курса содержит "Python" + + Сценарий: Поиск курсов, стартующих в указанную дату или позже + Дано Открыт браузер Chrome + Когда Открыт каталог курсов + И Найдены курсы со стартом "01.01.2025" или позже + Тогда В консоль выведены найденные курсы и даты старта + + Сценарий: Поиск самого дорогого и самого дешевого подготовительного курса + Дано Открыт браузер Chrome + Когда Открыт раздел Обучение и Подготовительные курсы + И Выбраны самый дорогой и самый дешевый курсы с помощью filter + Тогда В консоль выведена информация о самом дорогом и самом дешевом курсе + + Сценарий: Поиск самого дорогого и самого дешевого курса в каталоге по полной стоимости со скидкой + Дано Открыт браузер Chrome + Когда В каталоге курсов выбраны самый дорогой и самый дешевый курсы по полной стоимости со скидкой с помощью filter + Тогда В консоль выведена информация о самом дорогом и самом дешевом курсе в каталоге diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 0000000..ec7d72c --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,15 @@ + + + + + %d{HH:mm:ss} %-5level %logger{36} - %msg%n + + + + + + + + + + diff --git a/src/test/resources/logging.properties b/src/test/resources/logging.properties new file mode 100644 index 0000000..3da1c9c --- /dev/null +++ b/src/test/resources/logging.properties @@ -0,0 +1,8 @@ +handlers= java.util.logging.ConsoleHandler +.level= WARNING + +java.util.logging.ConsoleHandler.level = SEVERE +java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter + +org.openqa.selenium.devtools.level = SEVERE +org.openqa.selenium.devtools.CdpVersionFinder.level = SEVERE