diff --git a/README.md b/README.md index 2d4bf61..df27e59 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # OTUS Homework 6: Playwright UI Tests - -UI-автотесты на Playwright для 4 сценариев OTUS. +UI-автотесты на Playwright для 4 сценариев OTUS с DI и трассировкой. ## Что реализовано - 4 UI-сценария из ТЗ (Clickhouse, Catalog, B2B, Subscription). @@ -9,6 +8,18 @@ UI-автотесты на Playwright для 4 сценариев OTUS. - Линтеры: Checkstyle и SpotBugs. - Запуск из консоли (Maven). +## Сценарии +1. **Clickhouse**: блок преподавателей, drag&drop, popup, next/prev в карточке. +2. **Каталог курсов**: фильтры по направлению/уровню/длительности, проверка изменения карточек. +3. **Услуги компаниям**: переход в “Разработка курса для бизнеса”, направления, переход в каталог. +4. **Подписки**: раскрытие/сворачивание описания, переход к оплате, выбор Trial. + +## Стек и версии +- Java: 21 +- Playwright: 1.58.0 +- JUnit: 5.10.1 +- Guice: 7.0.0 + ## Структура проекта - `src/test/java/ru/kovbasa/tests` — тесты по сценариям. - `src/test/java/ru/kovbasa/pages` — page-объекты. @@ -17,38 +28,18 @@ UI-автотесты на Playwright для 4 сценариев OTUS. - `traces/` — zip-трейсы по каждому тесту. - `traces.zip` — архив для сдачи. -## Сценарии -1. **Clickhouse**: блок преподавателей, drag&drop, popup, next/prev в карточке. -2. **Каталог курсов**: фильтры по направлению/уровню/длительности, проверка изменения карточек. -3. **Услуги компаниям**: переход в “Разработка курса для бизнеса”, направления, переход в каталог. -4. **Подписки**: раскрытие/сворачивание описания, переход к оплате, выбор Trial. - -## Версии -- Java: 21 -- Playwright: 1.58.0 -- JUnit: 5.10.1 -- Guice: 7.0.0 - -## Требования из ТЗ -- Использование DI — реализовано через Guice. -- Использование линтеров — Checkstyle и SpotBugs включены в `mvn verify`. -- Только Playwright — другие UI-фреймворки не используются. -- Трассировка для всех тестов — включена в фикстуре `PlaywrightExtension`. -- `@UsePlaywright` не используется — ресурсы создаются и инжектятся вручную. -- Запуск из консоли — `mvn test`. - -## Как запускать -### Все тесты +## Команды запуска +Все тесты: ```bash mvn test ``` -### Один тест -```bash +Один тест (PowerShell): +```powershell mvn "-Dtest=ru.kovbasa.tests.CatalogFiltersTest" test ``` -### Параметры запуска +Параметры запуска: ```bash mvn -Dheadless=false -Dbrowser=chromium -DbaseUrl=https://otus.ru test ``` @@ -63,7 +54,7 @@ mvn -Dheadless=false -Dbrowser=chromium -DbaseUrl=https://otus.ru test ## Трейсы Трейсы автоматически сохраняются в каталог `traces/` в виде zip-файлов по каждому тесту. -Для сдачи ДЗ нужно положить `traces.zip` в корень проекта. +Архив `traces.zip` расположен в корне проекта. PowerShell: ```powershell @@ -80,10 +71,7 @@ zip -r traces.zip traces mvn verify ``` -## Кросс‑платформенность -Проект запускается на Windows / Linux / macOS. - -Минимальные требования: +## Требования к окружению - Java 21 - Maven diff --git a/src/test/java/ru/kovbasa/pages/CatalogPage.java b/src/test/java/ru/kovbasa/pages/CatalogPage.java index a717e72..035ec7a 100644 --- a/src/test/java/ru/kovbasa/pages/CatalogPage.java +++ b/src/test/java/ru/kovbasa/pages/CatalogPage.java @@ -6,7 +6,6 @@ import com.microsoft.playwright.Page; import com.microsoft.playwright.PlaywrightException; import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import ru.kovbasa.playwright.TestConfig; @@ -21,6 +20,7 @@ public class CatalogPage extends BasePage { public void open() { openPath("/catalog/courses"); + page.getByText("Направление").first().waitFor(); } public boolean isDefaultDirectionSelected() { @@ -34,25 +34,32 @@ public class CatalogPage extends BasePage { public void setDurationRange(int minMonths, int maxMonths) { Locator durationSection = getFilterSection("Продолжительность"); + for (int attempt = 0; attempt < 2; attempt++) { + if (trySetDurationRange(durationSection, minMonths, maxMonths)) { + return; + } + } + } - Locator rangeLabel = durationSection.locator("label").filter( + private boolean trySetDurationRange(Locator durationSection, int minMonths, int maxMonths) { + Locator rangeLabel = durationSection.locator("label:visible").filter( new Locator.FilterOptions().setHasText(String.valueOf(minMonths)) ).filter(new Locator.FilterOptions().setHasText(String.valueOf(maxMonths))).first(); if (rangeLabel.count() > 0) { rangeLabel.click(); - waitForDurationFilterApplied(minMonths, maxMonths); - return; + clickApplyIfPresent(durationSection); + return waitForDurationFilterApplied(minMonths, maxMonths); } Locator rangeText = durationSection.locator( "text=/\\b" + minMonths + "\\b.*\\b" + maxMonths + "\\b/").first(); if (rangeText.count() > 0) { rangeText.click(); - waitForDurationFilterApplied(minMonths, maxMonths); - return; + clickApplyIfPresent(durationSection); + return waitForDurationFilterApplied(minMonths, maxMonths); } - Locator sliders = page.locator("[role='slider']"); + Locator sliders = durationSection.locator("[role='slider']:visible"); if (sliders.count() >= 2) { Locator minSlider = sliders.nth(0); Locator maxSlider = sliders.nth(1); @@ -75,38 +82,43 @@ public class CatalogPage extends BasePage { nudgeSliderWithKeyboard(minSlider, minMonths); nudgeSliderWithKeyboard(maxSlider, maxMonths); waitForSliderValues(minSlider, maxSlider, minMonths, maxMonths); - waitForDurationFilterApplied(minMonths, maxMonths); - return; + clickApplyIfPresent(durationSection); + return waitForDurationFilterApplied(minMonths, maxMonths); } - Locator inputs = durationSection.locator("input"); + Locator inputs = durationSection.locator("input:visible"); if (inputs.count() >= 2) { String type = inputs.nth(0).getAttribute("type"); if (type != null && (type.equals("number") || type.equals("text"))) { inputs.nth(0).fill(String.valueOf(minMonths)); inputs.nth(1).fill(String.valueOf(maxMonths)); inputs.nth(1).press("Enter"); - return; + clickApplyIfPresent(durationSection); + return waitForDurationFilterApplied(minMonths, maxMonths); } } - Locator rangeInputs = durationSection.locator("input[type='range']"); + Locator rangeInputs = durationSection.locator("input[type='range']:visible"); if (rangeInputs.count() >= 2) { rangeInputs.nth(0).fill(String.valueOf(minMonths)); rangeInputs.nth(1).fill(String.valueOf(maxMonths)); - return; + clickApplyIfPresent(durationSection); + return waitForDurationFilterApplied(minMonths, maxMonths); } // no suitable controls found; rely on current state + return false; } public void selectDirection(String direction) { - Locator option = page.getByText(direction).first(); - try { - option.click(new Locator.ClickOptions().setForce(true)); - } catch (PlaywrightException ex) { - option.evaluate("el => el.click()"); + Locator directionSection = getFilterSection("Направление"); + Locator option = directionSection.locator("label") + .filter(new Locator.FilterOptions().setHasText(direction)) + .first(); + if (option.count() == 0) { + option = page.getByText(direction).first(); } + option.click(new Locator.ClickOptions().setForce(true)); } public boolean isDirectionSelected(String direction) { @@ -165,13 +177,28 @@ public class CatalogPage extends BasePage { private Locator getFilterSection(String filterLabel) { Locator title = page.getByText(filterLabel).first(); - Locator section = title.locator("xpath=ancestor::*[self::div or self::section][1]"); + Locator header = title.locator("xpath=ancestor::*[self::div or self::section][1]"); + Locator section = title.locator("xpath=ancestor::*[self::div or self::section][2]"); if (section.count() == 0) { - section = title.locator("xpath=ancestor::*[self::div or self::section][2]"); + section = header; + } + if (!hasFilterControls(section)) { + Locator toggle = header.locator("button").first(); + if (toggle.count() > 0) { + toggle.click(new Locator.ClickOptions().setForce(true)); + Locator anyControl = section.locator("label:visible, input:visible, [role='slider']:visible").first(); + if (anyControl.count() > 0) { + anyControl.waitFor(new Locator.WaitForOptions().setTimeout(5000)); + } + } } return section; } + private boolean hasFilterControls(Locator section) { + return section.locator("label:visible, input:visible, [role='slider']:visible").count() > 0; + } + private boolean isOptionChecked(String optionText) { Locator label = page.locator("label") .filter(new Locator.FilterOptions().setHasText(optionText)) @@ -206,8 +233,7 @@ public class CatalogPage extends BasePage { Locator list = page.locator("section, div").filter( new Locator.FilterOptions().setHasText("Показать еще") ).first(); - Locator cards = list.locator("a[href*='/lessons/']:visible") - .filter(new Locator.FilterOptions().setHasNotText("Успеть!")); + Locator cards = list.locator("a[href*='/lessons/']:visible"); if (cards.count() == 0) { cards = list.locator("a:has(h4):visible, a:has(h5):visible, a:has(h6):visible"); } @@ -228,19 +254,18 @@ public class CatalogPage extends BasePage { if (targetValue > max) { targetValue = max; } - @SuppressWarnings("unchecked") - Map trackBox = (Map) slider.evaluate("el => {" - + "const track = el.parentElement;" - + "const r = track.getBoundingClientRect();" - + "return {x: r.x, y: r.y, width: r.width, height: r.height};" - + "}"); + var track = slider.locator("xpath=.."); + var trackBox = track.boundingBox(); + if (trackBox == null) { + trackBox = slider.boundingBox(); + } if (trackBox == null) { return; } - double x = ((Number) trackBox.get("x")).doubleValue(); - double width = ((Number) trackBox.get("width")).doubleValue(); - double y = ((Number) trackBox.get("y")).doubleValue(); - double height = ((Number) trackBox.get("height")).doubleValue(); + double x = trackBox.x; + double width = trackBox.width; + double y = trackBox.y; + double height = trackBox.height; double ratio = (targetValue - min) / (double) (max - min); double targetX = x + width * ratio; double targetY = y + height / 2; @@ -276,15 +301,21 @@ public class CatalogPage extends BasePage { } } - private void waitForDurationFilterApplied(int minMonths, int maxMonths) { + private boolean waitForDurationFilterApplied(int minMonths, int maxMonths) { + boolean appliedOnce = false; for (int i = 0; i < 40; i++) { List durations = getVisibleCourseDurations(); if (!durations.isEmpty() && durations.stream().allMatch(v -> v >= minMonths && v <= maxMonths)) { - return; + return true; + } + if (!appliedOnce) { + clickApplyGlobalIfPresent(); + appliedOnce = true; } page.waitForTimeout(300); } + return false; } private void waitForFiltersReset() { @@ -314,4 +345,25 @@ public class CatalogPage extends BasePage { page.waitForTimeout(200); } } + + private void clickApplyIfPresent(Locator durationSection) { + Locator apply = durationSection.locator("button:has-text('Применить')," + + " button:has-text('Показать')," + + " button:has-text('Готово')," + + " button:has-text('Сохранить')").first(); + if (apply.count() > 0) { + apply.click(new Locator.ClickOptions().setForce(true)); + } + } + + private void clickApplyGlobalIfPresent() { + Locator apply = page.locator("button:has-text('Применить')," + + " button:has-text('Показать')," + + " button:has-text('Готово')," + + " button:has-text('Сохранить')") + .first(); + if (apply.count() > 0) { + apply.click(new Locator.ClickOptions().setForce(true)); + } + } } diff --git a/src/test/java/ru/kovbasa/pages/ClickhousePage.java b/src/test/java/ru/kovbasa/pages/ClickhousePage.java index 7060c7a..ff6fa09 100644 --- a/src/test/java/ru/kovbasa/pages/ClickhousePage.java +++ b/src/test/java/ru/kovbasa/pages/ClickhousePage.java @@ -1,11 +1,9 @@ package ru.kovbasa.pages; -import com.microsoft.playwright.ElementHandle; import com.microsoft.playwright.Locator; import com.microsoft.playwright.Mouse; import com.microsoft.playwright.Page; import com.microsoft.playwright.options.AriaRole; -import java.util.Optional; import ru.kovbasa.playwright.TestConfig; import ru.kovbasa.utils.UiActions; @@ -43,16 +41,11 @@ public class ClickhousePage extends BasePage { public void dragTeachersCarousel() { Locator section = getTeachersSection(); - ElementHandle scrollContainer = findScrollableContainer(section); - if (scrollContainer == null) { - return; - } + Locator activeCard = getActiveTeacherCard(); + String activeBefore = getCardName(activeCard); + activeCard.scrollIntoViewIfNeeded(); - Optional before = getScrollLeft(scrollContainer); - String activeBefore = getCardName(getActiveTeacherCard()); - scrollContainer.scrollIntoViewIfNeeded(); - - var box = scrollContainer.boundingBox(); + var box = activeCard.boundingBox(); if (box == null) { return; } @@ -66,35 +59,25 @@ public class ClickhousePage extends BasePage { page.mouse().move(endX, y, new Mouse.MoveOptions().setSteps(12)); page.mouse().up(); - if (before.isPresent()) { - page.waitForTimeout(500); - Optional after = getScrollLeft(scrollContainer); - if (after.isPresent() && before.get().equals(after.get())) { - scrollContainer.evaluate("el => el.scrollLeft += el.clientWidth"); - } - } else { - page.evaluate("() => {\n" - + " const swiperEl = document.querySelector('.swiper');\n" - + " if (swiperEl && swiperEl.swiper) { swiperEl.swiper.slideNext(); }\n" - + "}"); - } String activeAfter = getCardName(getActiveTeacherCard()); if (activeAfter.equals(activeBefore)) { - page.evaluate("() => {\n" - + " const swiperEl = document.querySelector('.swiper');\n" - + " if (swiperEl && swiperEl.swiper) { swiperEl.swiper.slideNext(); }\n" - + "}"); - page.waitForTimeout(300); + Locator nextButton = section.locator( + ".swiper-button-next, button[aria-label*='След'], button[aria-label*='Next']," + + " button:has-text('>')").first(); + if (nextButton.count() > 0) { + nextButton.click(new Locator.ClickOptions().setForce(true)); + } else { + page.keyboard().press("ArrowRight"); + } } } - public Optional getTeachersScrollLeft() { - Locator section = getTeachersSection(); - ElementHandle scrollContainer = findScrollableContainer(section); - return getScrollLeft(scrollContainer); - } - public String getPopupTeacherName() { + Locator popup = getPopupContainer(); + Locator active = popup.locator(".swiper-slide-active").first(); + if (active.count() > 0) { + return getCardName(active); + } return getCardName(getActiveTeacherCard()); } @@ -111,57 +94,25 @@ public class ClickhousePage extends BasePage { } public void clickPopupNext() { - Locator nextButton = page.locator( - ".swiper-button-next, button[aria-label*='След'], button[aria-label*='Next']," - + " button:has-text('>')").first(); - if (nextButton.count() > 0) { - nextButton.click(new Locator.ClickOptions().setForce(true)); - return; - } - Boolean moved = (Boolean) page.evaluate("() => {\n" - + " const swiperEl = document.querySelector('.swiper');\n" - + " if (swiperEl && swiperEl.swiper) { swiperEl.swiper.slideNext(); return true; }\n" - + " return false;\n" - + "}"); - if (!Boolean.TRUE.equals(moved)) { - page.keyboard().press("ArrowRight"); - } + String before = getPopupTeacherName(); + clickPopupArrow(before, true); } public void clickPopupPrev() { String before = getPopupTeacherName(); - Locator prevButton = page.locator( - ".swiper-button-prev, button[aria-label*='Пред'], button[aria-label*='Prev']," - + " button:has-text('<')").first(); - if (prevButton.count() > 0) { - prevButton.click(new Locator.ClickOptions().setForce(true)); - } else { - Boolean moved = (Boolean) page.evaluate("() => {\n" - + " const swiperEl = document.querySelector('.swiper');\n" - + " if (swiperEl && swiperEl.swiper) { swiperEl.swiper.slidePrev(); return true; }\n" - + " return false;\n" - + "}"); - if (!Boolean.TRUE.equals(moved)) { - page.keyboard().press("ArrowLeft"); - } - } - page.waitForTimeout(300); - String after = getPopupTeacherName(); - if (after.equals(before)) { - page.evaluate("() => {\n" - + " const swiperEl = document.querySelector('.swiper');\n" - + " if (swiperEl && swiperEl.swiper) { swiperEl.swiper.slidePrev(); }\n" - + "}"); - page.waitForTimeout(300); - } + clickPopupArrow(before, false); } public Locator getTeacherDialog() { + Locator dialog = page.locator("[role='dialog'], [class*='modal'], [class*='popup']").first(); + if (dialog.count() > 0) { + return dialog; + } Locator active = getActiveTeacherCard(); if (active.count() > 0) { return active; } - return page.locator("[role='dialog'], [class*='modal'], [class*='popup']").first(); + return page.locator("body"); } public Locator getActiveTeacherCard() { @@ -198,28 +149,41 @@ public class ClickhousePage extends BasePage { return section; } - private ElementHandle findScrollableContainer(Locator section) { - String script = """ - section => { - const byClass = section.querySelector( - '[class*="swiper"], [class*="carousel"], [class*="slider"],' - + ' [class*="scroll"], [class*="teachers"]' - ); - if (byClass) { - return byClass; - } - return Array.from(section.querySelectorAll('*')) - .find(el => el.scrollWidth > el.clientWidth); - } - """; - return section.evaluateHandle(script).asElement(); + private void waitForPopupTeacherChange(String before, boolean forward) { + for (int i = 0; i < 6; i++) { + page.waitForTimeout(300); + String after = getPopupTeacherName(); + if (!after.equals(before)) { + return; + } + if (i == 2) { + page.keyboard().press(forward ? "ArrowRight" : "ArrowLeft"); + } + } } - private Optional getScrollLeft(ElementHandle handle) { - if (handle == null) { - return Optional.empty(); + private void clickPopupArrow(String before, boolean forward) { + Locator popup = getPopupContainer(); + Locator buttons = popup.locator( + "xpath=.//div[.//*[contains(@class,'swiper-pagination')]]//button"); + int count = (int) Math.min(buttons.count(), 2); + if (count > 0) { + int index = forward ? Math.max(0, count - 1) : 0; + buttons.nth(index).click(new Locator.ClickOptions().setForce(true)); + waitForPopupTeacherChange(before, forward); + return; } - Number value = (Number) handle.evaluate("el => el.scrollLeft"); - return value == null ? Optional.empty() : Optional.of(value.doubleValue()); + page.keyboard().press(forward ? "ArrowRight" : "ArrowLeft"); + waitForPopupTeacherChange(before, forward); } + + private Locator getPopupContainer() { + Locator popup = page.locator(".fade-enter-active, .fade-enter-done").first(); + if (popup.count() > 0) { + return popup; + } + return page.locator("body"); + } + + // no JS helpers: rely on locators and user-like interactions } diff --git a/src/test/java/ru/kovbasa/tests/ClickhouseTeachersTest.java b/src/test/java/ru/kovbasa/tests/ClickhouseTeachersTest.java index b674cb4..ab4e418 100644 --- a/src/test/java/ru/kovbasa/tests/ClickhouseTeachersTest.java +++ b/src/test/java/ru/kovbasa/tests/ClickhouseTeachersTest.java @@ -20,17 +20,17 @@ public class ClickhouseTeachersTest extends BaseTest { Locator activeCard = clickhouse.getActiveTeacherCard(); String firstNameBefore = getCardName(activeCard); - var scrollBefore = clickhouse.getTeachersScrollLeft(); clickhouse.dragTeachersCarousel(); activeCard = clickhouse.getActiveTeacherCard(); String firstNameAfter = getCardName(activeCard); - var scrollAfter = clickhouse.getTeachersScrollLeft(); - boolean scrolled = scrollBefore.isPresent() && scrollAfter.isPresent() - && !scrollBefore.get().equals(scrollAfter.get()); - boolean nameChanged = !firstNameBefore.equals(firstNameAfter); - assertTrue(scrolled || nameChanged, + if (firstNameBefore.equals(firstNameAfter)) { + clickhouse.dragTeachersCarousel(); + activeCard = clickhouse.getActiveTeacherCard(); + firstNameAfter = getCardName(activeCard); + } + assertTrue(!firstNameBefore.equals(firstNameAfter), "Teacher list should be scrolled after drag"); clickhouse.openTeacherPopup(activeCard); diff --git a/traces.zip b/traces.zip index 8dd1296..3d10be5 100644 Binary files a/traces.zip and b/traces.zip differ