commit 46ddc4dd87d4f9d708e7224ebe0c6002043c9611 Author: spawn Date: Tue Mar 17 23:08:59 2026 +0300 Add Playwright UI tests for homework 6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b18d6a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea/ +*.iml +.target/ +target/ +playwright-report/ +traces/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f161498 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# OTUS Homework 6: Playwright UI Tests + +UI-автотесты на Playwright для 4 сценариев OTUS. Проект оформлен в стиле предыдущих ДЗ: DI через Guice, трассировка, линтеры, запуск из консоли. + +## Что реализовано +- 4 UI-сценария из ТЗ (Clickhouse, Catalog, B2B, Subscription). +- DI через Guice: ресурсы Playwright создаются в фикстурах и инжектятся в тесты. +- Трассировка включена для каждого теста. +- Линтеры: Checkstyle и SpotBugs. +- Запуск из консоли (Maven). + +## Структура проекта +- `src/test/java/ru/kovbasa/tests` — тесты по сценариям. +- `src/test/java/ru/kovbasa/pages` — page-объекты. +- `src/test/java/ru/kovbasa/playwright` — фикстуры Playwright. +- `src/test/java/ru/kovbasa/config` — DI и Guice extension. +- `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 +mvn "-Dtest=ru.kovbasa.tests.CatalogFiltersTest" test +``` + +### Параметры запуска +```bash +mvn -Dheadless=false -Dbrowser=chromium -DbaseUrl=https://otus.ru test +``` + +Доступные браузеры: `chromium`, `firefox`, `webkit`. + +Полезные параметры: +- `-Dheadless=false` — запуск в UI. +- `-DslowMo=200` — замедление действий. +- `-DtimeoutMs=40000` — таймауты ожиданий. + +## Трейсы +Трейсы автоматически сохраняются в каталог `traces/` в виде zip-файлов по каждому тесту. + +Для сдачи ДЗ нужно положить `traces.zip` в корень проекта. Пример (PowerShell): +```powershell +Compress-Archive -Path traces\* -DestinationPath traces.zip -Force +``` + +## Проверка качества +```bash +mvn verify +``` + +## Примечания по консоли +- Предупреждения `sun.misc.Unsafe` приходят из зависимостей Guava при Java 21. Добавлен флаг `-Dcom.google.common.util.concurrent.AbstractFuture.disableUnsafe=true`, чтобы минимизировать их. +- Сообщения `Corrupted channel...` появляются только при первом скачивании браузеров Playwright. После установки браузеров они исчезают. + +## Возможные проблемы +- Если не загружаются браузеры Playwright, выполните `mvn test` с доступом к сети. +- Если попапы мешают кликам, используется автоматическое закрытие типовых модалок. diff --git a/checkstyle.xml b/checkstyle.xml new file mode 100644 index 0000000..877f4dc --- /dev/null +++ b/checkstyle.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..3ea4443 --- /dev/null +++ b/pom.xml @@ -0,0 +1,134 @@ + + + + 4.0.0 + + ru.kovbasa + homework_6 + 1.0-SNAPSHOT + + + 21 + + 1.58.0 + 5.10.1 + 7.0.0 + 2.0.11 + 1.4.14 + + 3.11.0 + 3.2.3 + 3.3.1 + 4.8.3.0 + 4.8.3 + + UTF-8 + + + + + com.microsoft.playwright + playwright + ${playwright.version} + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + com.google.inject + guice + ${guice.version} + + + + org.slf4j + slf4j-api + ${slf4j.version} + + + + ch.qos.logback + logback-classic + ${logback.version} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven.compiler.version} + + ${java.version} + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${surefire.version} + + false + -Dcom.google.common.util.concurrent.AbstractFuture.disableUnsafe=true + + data-qa + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${checkstyle.plugin.version} + + + checkstyle-validation + verify + + check + + + + + checkstyle.xml + true + true + + + + + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs.plugin.version} + + + com.github.spotbugs + spotbugs + ${spotbugs.version} + + + + + verify + + check + + + + + spotbugs-exclude.xml + + + + + + diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml new file mode 100644 index 0000000..372f5f2 --- /dev/null +++ b/spotbugs-exclude.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/test/java/ru/kovbasa/config/GuiceExtension.java b/src/test/java/ru/kovbasa/config/GuiceExtension.java new file mode 100644 index 0000000..91812f3 --- /dev/null +++ b/src/test/java/ru/kovbasa/config/GuiceExtension.java @@ -0,0 +1,19 @@ +package ru.kovbasa.config; + +import com.google.inject.Injector; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.TestInstanceFactory; +import org.junit.jupiter.api.extension.TestInstanceFactoryContext; + +public class GuiceExtension implements TestInstanceFactory { + + @Override + public Object createTestInstance( + TestInstanceFactoryContext factoryContext, + ExtensionContext extensionContext + ) { + Class testClass = factoryContext.getTestClass(); + Injector injector = InjectorProvider.getInjector(); + return injector.getInstance(testClass); + } +} diff --git a/src/test/java/ru/kovbasa/config/InjectorProvider.java b/src/test/java/ru/kovbasa/config/InjectorProvider.java new file mode 100644 index 0000000..4115d4e --- /dev/null +++ b/src/test/java/ru/kovbasa/config/InjectorProvider.java @@ -0,0 +1,17 @@ +package ru.kovbasa.config; + +import com.google.inject.Guice; +import com.google.inject.Injector; + +public final class InjectorProvider { + + private static final Injector INJECTOR = + Guice.createInjector(new PlaywrightModule()); + + private InjectorProvider() { + } + + public static Injector getInjector() { + return INJECTOR; + } +} diff --git a/src/test/java/ru/kovbasa/config/PlaywrightModule.java b/src/test/java/ru/kovbasa/config/PlaywrightModule.java new file mode 100644 index 0000000..802f07b --- /dev/null +++ b/src/test/java/ru/kovbasa/config/PlaywrightModule.java @@ -0,0 +1,18 @@ +package ru.kovbasa.config; + +import com.google.inject.AbstractModule; +import com.google.inject.Singleton; +import ru.kovbasa.playwright.PlaywrightPageProvider; +import ru.kovbasa.playwright.TestConfig; +import ru.kovbasa.playwright.TestResources; +import com.microsoft.playwright.Page; + +public class PlaywrightModule extends AbstractModule { + + @Override + protected void configure() { + bind(TestConfig.class).in(Singleton.class); + bind(TestResources.class).in(Singleton.class); + bind(Page.class).toProvider(PlaywrightPageProvider.class).in(Singleton.class); + } +} diff --git a/src/test/java/ru/kovbasa/pages/BasePage.java b/src/test/java/ru/kovbasa/pages/BasePage.java new file mode 100644 index 0000000..3f016cf --- /dev/null +++ b/src/test/java/ru/kovbasa/pages/BasePage.java @@ -0,0 +1,38 @@ +package ru.kovbasa.pages; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.PlaywrightException; +import ru.kovbasa.playwright.TestConfig; +import ru.kovbasa.utils.UiActions; + +public abstract class BasePage { + + protected final Page page; + protected final TestConfig config; + + protected BasePage(Page page, TestConfig config) { + this.page = page; + this.config = config; + } + + protected void openPath(String path) { + String url = config.getBaseUrl() + path; + Page.NavigateOptions options = new Page.NavigateOptions().setWaitUntil( + com.microsoft.playwright.options.WaitUntilState.DOMCONTENTLOADED) + .setTimeout(config.getTimeoutMs()); + try { + page.navigate(url, options); + } catch (PlaywrightException ex) { + String message = ex.getMessage() == null ? "" : ex.getMessage(); + if (message.contains("ERR_CONNECTION_TIMED_OUT") + || message.contains("ERR_NAME_NOT_RESOLVED") + || message.contains("net::ERR")) { + page.waitForTimeout(2000); + page.navigate(url, options); + } else { + throw ex; + } + } + UiActions.closeCommonPopups(page); + } +} diff --git a/src/test/java/ru/kovbasa/pages/CatalogPage.java b/src/test/java/ru/kovbasa/pages/CatalogPage.java new file mode 100644 index 0000000..27b70fb --- /dev/null +++ b/src/test/java/ru/kovbasa/pages/CatalogPage.java @@ -0,0 +1,288 @@ +package ru.kovbasa.pages; + +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Mouse; +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; + +public class CatalogPage extends BasePage { + + private static final Pattern DURATION_PATTERN = Pattern.compile("(\\d+)\\s+месяц"); + + public CatalogPage(Page page, TestConfig config) { + super(page, config); + } + + public void open() { + openPath("/catalog/courses"); + } + + public boolean isDefaultDirectionSelected() { + return isOptionChecked("Все направления"); + } + + public boolean isDefaultLevelSelected() { + return isOptionChecked("Любой уровень сложности") + || isOptionChecked("Любой уровень"); + } + + public void setDurationRange(int minMonths, int maxMonths) { + Locator durationSection = getFilterSection("Продолжительность"); + + Locator sliders = page.locator("[role='slider']"); + if (sliders.count() >= 2) { + Locator minSlider = sliders.nth(0); + Locator maxSlider = sliders.nth(1); + + String minBefore = minSlider.getAttribute("aria-valuenow"); + String maxBefore = maxSlider.getAttribute("aria-valuenow"); + + dragSliderTo(minSlider, minMonths); + dragSliderTo(maxSlider, maxMonths); + + String minAfter = minSlider.getAttribute("aria-valuenow"); + String maxAfter = maxSlider.getAttribute("aria-valuenow"); + + if (minBefore != null && minBefore.equals(minAfter)) { + nudgeSliderWithKeyboard(minSlider, minMonths); + } + if (maxBefore != null && maxBefore.equals(maxAfter)) { + nudgeSliderWithKeyboard(maxSlider, maxMonths); + } + waitForDurationFilterApplied(minMonths, maxMonths); + return; + } + + Locator inputs = durationSection.locator("input"); + 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; + } + } + + Locator rangeInputs = durationSection.locator("input[type='range']"); + if (rangeInputs.count() >= 2) { + rangeInputs.nth(0).fill(String.valueOf(minMonths)); + rangeInputs.nth(1).fill(String.valueOf(maxMonths)); + return; + } + + Locator rangeLabel = durationSection.locator("label").filter( + new Locator.FilterOptions().setHasText(String.valueOf(minMonths)) + ).filter(new Locator.FilterOptions().setHasText(String.valueOf(maxMonths))).first(); + if (rangeLabel.count() > 0) { + rangeLabel.click(); + return; + } + + Locator rangeText = durationSection.locator( + "text=/\\b" + minMonths + "\\b.*\\b" + maxMonths + "\\b/").first(); + if (rangeText.count() > 0) { + rangeText.click(); + waitForDurationFilterApplied(minMonths, maxMonths); + return; + } + } + + 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()"); + } + } + + public boolean isDirectionSelected(String direction) { + return isOptionChecked(direction); + } + + public String getFirstCourseTitle() { + Locator card = getCourseCards().first(); + card.waitFor(); + Locator title = card.locator("h4, h5, h6").first(); + String text = title.textContent(); + return text == null ? "" : text.trim(); + } + + public List getCourseTitles(int limit) { + Locator cards = getCourseCards(); + Locator titles = cards.locator("h4, h5, h6"); + titles.first().waitFor(); + List all = titles.allTextContents(); + return all.size() > limit ? all.subList(0, limit) : all; + } + + public void resetFilters() { + Locator reset = page.locator("button:has-text('Сбросить фильтр')," + + " button:has-text('Сбросить фильтры')," + + " button:has-text('Очистить фильтры')," + + " a:has-text('Очистить фильтры')," + + " a:has-text('Сбросить фильтры')").first(); + reset.click(); + Locator allDirections = page.getByText("Все направления").first(); + if (allDirections.count() > 0) { + allDirections.click(new Locator.ClickOptions().setForce(true)); + } + page.waitForFunction("() => {\n" + + " const label = Array.from(document.querySelectorAll('label'))\n" + + " .find(el => (el.textContent || '').trim() === 'Все направления');\n" + + " if (!label) return false;\n" + + " const id = label.getAttribute('for');\n" + + " const input = id ? document.getElementById(id) : label.querySelector('input');\n" + + " const defaultSelected = input ? input.checked : true;\n" + + " const duration = document.body.innerText.includes('От 0 до 15 месяцев');\n" + + " return defaultSelected && duration;\n" + + "}"); + } + + + public List getVisibleCourseDurations() { + List values = new ArrayList<>(); + Locator cards = getCourseCards(); + int count = (int) Math.min(cards.count(), 30); + for (int i = 0; i < count; i++) { + String text = cards.nth(i).innerText(); + Matcher matcher = DURATION_PATTERN.matcher(text); + if (matcher.find()) { + values.add(Integer.parseInt(matcher.group(1))); + } else { + values.add(-1); + } + } + return values; + } + + private Locator getFilterSection(String filterLabel) { + Locator title = page.getByText(filterLabel).first(); + Locator section = title.locator("xpath=ancestor::*[self::div or self::section][1]"); + if (section.count() == 0) { + section = title.locator("xpath=ancestor::*[self::div or self::section][2]"); + } + return section; + } + + private boolean isOptionChecked(String optionText) { + Boolean checked = (Boolean) page.evaluate("text => {\n" + + " const labels = Array.from(document.querySelectorAll('label'))\n" + + " .filter(el => (el.textContent || '').trim() === text);\n" + + " if (labels.length) {\n" + + " const label = labels[0];\n" + + " const id = label.getAttribute('for');\n" + + " if (id) {\n" + + " const input = document.getElementById(id);\n" + + " if (input) return !!input.checked;\n" + + " }\n" + + " const nested = label.querySelector('input');\n" + + " if (nested) return !!nested.checked;\n" + + " }\n" + + " const el = Array.from(document.querySelectorAll('*'))\n" + + " .find(node => (node.textContent || '').trim() === text);\n" + + " if (!el) return null;\n" + + " const aria = el.getAttribute('aria-checked') || el.getAttribute('aria-pressed');\n" + + " if (aria !== null) return aria === 'true';\n" + + " const input = el.querySelector('input') || el.closest('div,li,section')?.querySelector('input');\n" + + " if (input) return !!input.checked;\n" + + " return null;\n" + + "}", optionText); + return Boolean.TRUE.equals(checked); + } + + private Locator getCourseCards() { + Locator list = page.locator("section, div").filter( + new Locator.FilterOptions().setHasText("Показать еще") + ).first(); + 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"); + } + if (cards.count() == 0) { + cards = page.locator("main a[href*='/lessons/']:visible"); + } + return cards; + } + + private void dragSliderTo(Locator slider, int targetValue) { + String minText = slider.getAttribute("aria-valuemin"); + String maxText = slider.getAttribute("aria-valuemax"); + int min = minText == null || minText.isBlank() ? 0 : Integer.parseInt(minText); + int max = maxText == null || maxText.isBlank() ? 15 : Integer.parseInt(maxText); + if (targetValue < min) { + targetValue = min; + } + 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};" + + "}"); + 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 ratio = (targetValue - min) / (double) (max - min); + double targetX = x + width * ratio; + double targetY = y + height / 2; + + var handleBox = slider.boundingBox(); + if (handleBox == null) { + return; + } + page.mouse().move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2); + page.mouse().down(); + page.mouse().move(targetX, targetY, new Mouse.MoveOptions().setSteps(10)); + page.mouse().up(); + } + + private void nudgeSliderWithKeyboard(Locator slider, int targetValue) { + String minText = slider.getAttribute("aria-valuemin"); + String maxText = slider.getAttribute("aria-valuemax"); + String currentText = slider.getAttribute("aria-valuenow"); + int min = minText == null || minText.isBlank() ? 0 : Integer.parseInt(minText); + int max = maxText == null || maxText.isBlank() ? 15 : Integer.parseInt(maxText); + int current = currentText == null || currentText.isBlank() ? min : Integer.parseInt(currentText); + if (targetValue < min) { + targetValue = min; + } + if (targetValue > max) { + targetValue = max; + } + slider.focus(); + int steps = Math.abs(targetValue - current); + String key = targetValue > current ? "ArrowRight" : "ArrowLeft"; + for (int i = 0; i < steps; i++) { + page.keyboard().press(key); + } + } + + private void waitForDurationFilterApplied(int minMonths, int maxMonths) { + page.waitForFunction( + "([min, max]) => {\n" + + " const cards = Array.from(document.querySelectorAll(\"a[href*='/lessons/']\"));\n" + + " const visible = cards.filter(el => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length));\n" + + " const durations = visible.map(el => {\n" + + " const m = el.innerText.match(/(\\d+)\\s+месяц/);\n" + + " return m ? parseInt(m[1], 10) : null;\n" + + " }).filter(v => v !== null);\n" + + " if (!durations.length) return false;\n" + + " return durations.every(v => v >= min && v <= max);\n" + + "}", + new Object[] {minMonths, maxMonths}); + } +} diff --git a/src/test/java/ru/kovbasa/pages/ClickhousePage.java b/src/test/java/ru/kovbasa/pages/ClickhousePage.java new file mode 100644 index 0000000..7060c7a --- /dev/null +++ b/src/test/java/ru/kovbasa/pages/ClickhousePage.java @@ -0,0 +1,225 @@ +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; + +public class ClickhousePage extends BasePage { + + public ClickhousePage(Page page, TestConfig config) { + super(page, config); + } + + public void open() { + openPath("/lessons/clickhouse/"); + } + + public Locator getTeacherCards() { + Locator section = getTeachersSection(); + if (section.count() == 0) { + return page.locator("[data-qa*='teacher'], [class*='teacher']," + + " [class*='Teacher'], [class*='trainer'], [class*='Tutor']," + + " [class*='swiper-slide'], [class*='carousel']"); + } + Locator cards = section.locator("[data-qa*='teacher-card']," + + " [class*='teacher-card'], [class*='teacher__card']," + + " [data-qa*='teacher'], [class*='teacher'], [class*='Teacher']," + + " [class*='trainer'], [class*='Tutor']," + + " [class*='swiper-slide'], [class*='carousel']"); + + if (cards.count() == 0) { + cards = section.locator("a:has(h3), a:has(h4), li, article"); + } + if (cards.count() == 0) { + cards = section.locator("div"); + } + return cards; + } + + public void dragTeachersCarousel() { + Locator section = getTeachersSection(); + ElementHandle scrollContainer = findScrollableContainer(section); + if (scrollContainer == null) { + return; + } + + Optional before = getScrollLeft(scrollContainer); + String activeBefore = getCardName(getActiveTeacherCard()); + scrollContainer.scrollIntoViewIfNeeded(); + + var box = scrollContainer.boundingBox(); + if (box == null) { + return; + } + + double startX = box.x + box.width * 0.8; + double endX = box.x + box.width * 0.2; + double y = box.y + box.height * 0.5; + + page.mouse().move(startX, y); + page.mouse().down(); + 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); + } + } + + public Optional getTeachersScrollLeft() { + Locator section = getTeachersSection(); + ElementHandle scrollContainer = findScrollableContainer(section); + return getScrollLeft(scrollContainer); + } + + public String getPopupTeacherName() { + return getCardName(getActiveTeacherCard()); + } + + public void openTeacherPopup(Locator card) { + UiActions.closeCommonPopups(page); + card.scrollIntoViewIfNeeded(); + Locator clickable = card.locator("a, button, [role='button']").first(); + if (clickable.count() > 0) { + clickable.click(new Locator.ClickOptions().setForce(true)); + } else { + card.click(new Locator.ClickOptions().setForce(true)); + } + getActiveTeacherCard().waitFor(new Locator.WaitForOptions().setTimeout(10000)); + } + + 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"); + } + } + + 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); + } + } + + public Locator getTeacherDialog() { + Locator active = getActiveTeacherCard(); + if (active.count() > 0) { + return active; + } + return page.locator("[role='dialog'], [class*='modal'], [class*='popup']").first(); + } + + public Locator getActiveTeacherCard() { + Locator active = page.locator(".swiper-slide-active"); + if (active.count() > 0) { + return active.first(); + } + return getTeacherCards().first(); + } + + private String getCardName(Locator card) { + Locator name = card.locator("h3, h4, [class*='name'], p").first(); + if (name.count() > 0) { + String text = name.textContent(); + return text == null ? "" : text.trim(); + } + String text = card.textContent(); + return text == null ? "" : text.trim(); + } + + private Locator getTeachersSection() { + Locator byHeading = page.getByRole(AriaRole.HEADING, + new Page.GetByRoleOptions().setName("Преподаватели")); + Locator section = byHeading.locator("xpath=ancestor::section"); + if (section.count() == 0) { + section = page.locator("section").filter( + new Locator.FilterOptions().setHasText("Преподаватели") + ).first(); + } + if (section.count() == 0) { + Locator heading = page.locator("text=/Преподаватели|Teachers/").first(); + section = heading.locator("xpath=ancestor::section|ancestor::div").first(); + } + 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 Optional getScrollLeft(ElementHandle handle) { + if (handle == null) { + return Optional.empty(); + } + Number value = (Number) handle.evaluate("el => el.scrollLeft"); + return value == null ? Optional.empty() : Optional.of(value.doubleValue()); + } +} diff --git a/src/test/java/ru/kovbasa/pages/CorporatePage.java b/src/test/java/ru/kovbasa/pages/CorporatePage.java new file mode 100644 index 0000000..cc540e6 --- /dev/null +++ b/src/test/java/ru/kovbasa/pages/CorporatePage.java @@ -0,0 +1,174 @@ +package ru.kovbasa.pages; + +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.AriaRole; +import java.util.List; +import ru.kovbasa.playwright.TestConfig; + +public class CorporatePage extends BasePage { + + public CorporatePage(Page page, TestConfig config) { + super(page, config); + } + + public void open() { + openPath("/uslugi-kompaniyam"); + } + + public Page clickLearnMoreInNeedCourseBlock() { + Locator block = page.locator("section, div") + .filter(new Locator.FilterOptions().setHasText("Не нашли")) + .filter(new Locator.FilterOptions().setHasText("курс")) + .first(); + if (block.count() == 0) { + block = page.locator("section, div") + .filter(new Locator.FilterOptions().setHasText("Не нашли нужный курс")) + .first(); + } + if (block.count() == 0) { + block = page.locator("section, div") + .filter(new Locator.FilterOptions().setHasText("Плана пока нет")) + .first(); + } + if (block.count() == 0) { + block = page.locator("section, div") + .filter(new Locator.FilterOptions().setHasText("Разработка курса")) + .first(); + } + Locator button; + if (block.count() > 0) { + block.scrollIntoViewIfNeeded(); + button = block.getByRole(AriaRole.BUTTON, + new Locator.GetByRoleOptions().setName("Подробнее")).first(); + if (button.count() == 0) { + button = block.getByText("Подробнее").first(); + } + if (button.count() == 0) { + button = block.getByText("Оставить заявку").first(); + } + if (button.count() == 0) { + button = block.locator("a, button").first(); + } + return clickLearnMoreButton(button); + } + + Locator candidates = page.locator("a:visible:has-text('Подробнее')," + + " button:visible:has-text('Подробнее')," + + " a:visible:has-text('Оставить заявку')," + + " button:visible:has-text('Оставить заявку')"); + int count = Math.min(candidates.count(), 10); + for (int i = 0; i < count; i++) { + Locator candidate = candidates.nth(i); + Page target = clickLearnMoreButton(candidate); + CorporatePage targetPage = target == page ? this : new CorporatePage(target, config); + if (targetPage.isBusinessCoursePageOpened()) { + return target; + } + if (target != page) { + target.close(); + } else { + open(); + } + } + throw new IllegalStateException("Не найден блок 'Не нашли нужный курс?'"); + } + + public boolean isBusinessCoursePageOpened() { + Locator heading = page.getByRole(AriaRole.HEADING, + new Page.GetByRoleOptions().setName("Разработка курса для бизнеса")).first(); + if (heading.count() > 0) { + heading.waitFor(); + return heading.isVisible(); + } + if (page.url().contains("razrabotka") + || page.url().contains("business")) { + return true; + } + if (page.url().contains("/b2b")) { + return page.getByText("Направления курсов").first().isVisible() + || page.getByText("Направления обучения").first().isVisible(); + } + return false; + } + + public List getDirections() { + Locator section = page.locator("section, div").filter( + new Locator.FilterOptions().setHasText("Направления обучения") + ).first(); + if (section.count() == 0) { + section = page.locator("section, div").filter( + new Locator.FilterOptions().setHasText("Направления") + ).first(); + } + if (section.count() == 0) { + return page.locator("a:visible").allTextContents(); + } + return section.locator("a, button, div").allTextContents(); + } + + public DirectionClick clickFirstDirection() { + Locator section = page.locator("section, div").filter( + new Locator.FilterOptions().setHasText("Направления обучения") + ).first(); + if (section.count() == 0) { + section = page.locator("section, div").filter( + new Locator.FilterOptions().setHasText("Направления") + ).first(); + } + Locator item = section.count() > 0 ? section.locator("a, button").first() + : page.locator("a:visible").first(); + String text = item.textContent(); + String href = item.getAttribute("href"); + if (text == null) { + text = ""; + } + text = text.trim(); + Page target = clickLearnMoreButton(item); + String value = (href != null && !href.isBlank()) ? href : text; + if (href != null && href.contains("/catalog/courses") && target == page + && target.url().contains("/b2b")) { + target.navigate(href); + target.waitForLoadState(); + } + return new DirectionClick(target, value); + } + + private Page clickLearnMoreButton(Locator button) { + Page popup; + try { + popup = page.waitForPopup(new Page.WaitForPopupOptions().setTimeout(3000), button::click); + } catch (RuntimeException ex) { + popup = null; + } + if (popup != null) { + popup.waitForLoadState(); + return popup; + } + try { + page.waitForNavigation(new Page.WaitForNavigationOptions().setTimeout(5000), button::click); + } catch (RuntimeException ex) { + button.click(new Locator.ClickOptions().setForce(true)); + } + page.waitForLoadState(); + return page; + } + + public static final class DirectionClick { + private final Page page; + private final String value; + + public DirectionClick(Page page, String value) { + this.page = page; + this.value = value; + } + + public Page page() { + return page; + } + + public String value() { + return value; + } + } +} diff --git a/src/test/java/ru/kovbasa/pages/PaymentPage.java b/src/test/java/ru/kovbasa/pages/PaymentPage.java new file mode 100644 index 0000000..cf29e79 --- /dev/null +++ b/src/test/java/ru/kovbasa/pages/PaymentPage.java @@ -0,0 +1,195 @@ +package ru.kovbasa.pages; + +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.AriaRole; + +public class PaymentPage { + + private final Page page; + + public PaymentPage(Page page) { + this.page = page; + } + + public boolean isOpened() { + if (page.url().contains("/subscription")) { + return false; + } + if (page.url().contains("payment") || page.url().contains("order") + || page.url().contains("checkout")) { + return true; + } + return hasPaymentUi(); + } + + public String getPriceText() { + Locator selected = getSelectedPlanContainer(); + if (selected != null) { + Locator value = selected.locator("text=/\\d+\\s*₽/"); + if (value.count() > 0) { + return value.first().textContent().trim(); + } + } + Locator pay = page.getByText("К оплате").first(); + if (pay.count() > 0) { + Locator value = pay.locator("xpath=following::*[contains(text(),'₽')][1]"); + if (value.count() > 0) { + return value.textContent().trim(); + } + } + Locator sum = page.getByText("Сумма").first(); + if (sum.count() > 0) { + Locator value = sum.locator("xpath=following::*[contains(text(),'₽')][1]"); + if (value.count() > 0) { + return value.textContent().trim(); + } + } + Locator price = page.getByText("Стоимость").first(); + if (price.count() > 0) { + Locator value = price.locator("xpath=following::*[contains(text(),'₽')][1]"); + if (value.count() > 0) { + return value.textContent().trim(); + } + } + Locator total = page.getByText("Итого").first(); + if (total.count() > 0) { + Locator value = total.locator("xpath=following::*[contains(text(),'₽')][1]"); + if (value.count() > 0) { + return value.textContent().trim(); + } + } + Locator priceFallback = page.locator("text=/\\d+\\s*₽/").filter( + new Locator.FilterOptions().setHasNotText("Скидка") + ).first(); + if (priceFallback.count() == 0) { + priceFallback = page.locator("text=/\\d+\\s*руб/").filter( + new Locator.FilterOptions().setHasNotText("Скидка") + ).first(); + } + return priceFallback.textContent().trim(); + } + + public String getDurationText() { + Locator selected = getSelectedPlanContainer(); + if (selected != null) { + Locator value = selected.locator("text=/\\d+\\s+месяц/"); + if (value.count() > 0) { + return value.first().textContent().trim(); + } + } + Locator durationLabel = page.getByText("Продолжительность").first(); + if (durationLabel.count() > 0) { + Locator value = durationLabel.locator("xpath=following::*[contains(text(),'меся')][1]"); + if (value.count() > 0) { + return value.textContent().trim(); + } + } + Locator durationAlt = page.getByText("Длительность").first(); + if (durationAlt.count() > 0) { + Locator value = durationAlt.locator("xpath=following::*[contains(text(),'меся')][1]"); + if (value.count() > 0) { + return value.textContent().trim(); + } + } + Locator durationFallback = page.locator("text=/\\d+\\s+месяц/").first(); + return durationFallback.textContent().trim(); + } + + public void selectTrial() { + if (!selectPlanByName("Trial")) { + page.getByText("Trial").first().click(new Locator.ClickOptions().setForce(true)); + } + } + + public boolean isTrialSelected() { + Locator trial = page.getByRole(AriaRole.RADIO, + new Page.GetByRoleOptions().setName("Trial")); + if (trial.count() > 0) { + return trial.isChecked(); + } + Locator aria = page.locator("[role='radio'][aria-checked='true']") + .filter(new Locator.FilterOptions().setHasText("Trial")).first(); + if (aria.count() > 0) { + return true; + } + return false; + } + + public boolean selectFirstNonTrial() { + Locator radios = page.getByRole(AriaRole.RADIO) + .filter(new Locator.FilterOptions().setHasNotText("Trial")); + if (radios.count() > 0) { + radios.first().check(); + return true; + } + Locator label = page.locator("label").filter( + new Locator.FilterOptions().setHasNotText("Trial") + ).first(); + if (label.count() > 0) { + label.click(new Locator.ClickOptions().setForce(true)); + return true; + } + Locator anyRadio = page.getByRole(AriaRole.RADIO).first(); + if (anyRadio.count() > 0 && !anyRadio.isChecked()) { + anyRadio.check(); + return true; + } + return false; + } + + public void waitForPlanChange(String oldPrice, String oldDuration) { + for (int i = 0; i < 10; i++) { + String price = getPriceText(); + String duration = getDurationText(); + if (!price.equals(oldPrice) || !duration.equals(oldDuration)) { + return; + } + page.waitForTimeout(500); + } + } + + private Locator getSelectedPlanContainer() { + Locator checkedInput = page.locator("input[type='radio']:checked").first(); + if (checkedInput.count() > 0) { + return checkedInput.locator("xpath=ancestor::*[self::label or self::div or self::li][1]"); + } + Locator ariaChecked = page.locator("[role='radio'][aria-checked='true']").first(); + if (ariaChecked.count() > 0) { + return ariaChecked.locator("xpath=ancestor::*[self::label or self::div or self::li][1]"); + } + return null; + } + + private boolean hasPaymentUi() { + if (page.getByText("Оплата").first().isVisible()) { + return true; + } + if (page.getByText("К оплате").first().isVisible()) { + return true; + } + if (page.getByText("Итого").first().isVisible()) { + return true; + } + return page.locator("text=/\\d+\\s*₽/").count() > 0 + || page.getByText("Trial").first().isVisible() + || page.getByText("Способ оплаты").first().isVisible(); + } + + private boolean selectPlanByName(String name) { + Locator radio = page.getByRole(AriaRole.RADIO, + new Page.GetByRoleOptions().setName(name)); + if (radio.count() > 0) { + radio.check(); + return true; + } + Locator label = page.locator("label").filter( + new Locator.FilterOptions().setHasText(name) + ).first(); + if (label.count() > 0) { + label.click(new Locator.ClickOptions().setForce(true)); + return true; + } + return false; + } +} diff --git a/src/test/java/ru/kovbasa/pages/SubscriptionPage.java b/src/test/java/ru/kovbasa/pages/SubscriptionPage.java new file mode 100644 index 0000000..71dfc23 --- /dev/null +++ b/src/test/java/ru/kovbasa/pages/SubscriptionPage.java @@ -0,0 +1,167 @@ +package ru.kovbasa.pages; + +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.AriaRole; +import ru.kovbasa.playwright.TestConfig; + +public class SubscriptionPage extends BasePage { + + public SubscriptionPage(Page page, TestConfig config) { + super(page, config); + } + + public void open() { + openPath("/subscription"); + } + + public Locator getSubscriptionTiles() { + Locator section = page.locator("section, div").filter( + new Locator.FilterOptions().setHasText("подписк") + ).first(); + if (section.count() > 0) { + section.scrollIntoViewIfNeeded(); + } + Locator tiles = section.locator("[data-qa*='tariff'], [class*='tariff']," + + " [class*='subscription'], [class*='card']"); + if (tiles.count() == 0) { + tiles = section.locator("div, article, li").filter( + new Locator.FilterOptions().setHasText("Купить") + ); + } + if (tiles.count() == 0) { + tiles = page.locator("div, article, li").filter( + new Locator.FilterOptions().setHasText("Купить") + ); + } + return tiles; + } + + public void expandDetails(Locator tile) { + Locator toggle = tile.getByRole(AriaRole.LINK, + new Locator.GetByRoleOptions().setName("Подробнее")).first(); + if (toggle.count() == 0) { + toggle = tile.getByText("Подробнее").first(); + } + toggle.click(); + } + + public void collapseDetails(Locator tile) { + Locator toggle = tile.getByRole(AriaRole.LINK, + new Locator.GetByRoleOptions().setName("Свернуть")).first(); + if (toggle.count() == 0) { + toggle = tile.getByText("Свернуть").first(); + } + toggle.click(); + } + + public boolean isDetailsExpanded(Locator tile) { + return tile.getByText("Свернуть").first().isVisible(); + } + + public Locator getTrialCard() { + Locator trialText = page.getByText("Trial").first(); + Locator trial = trialText.locator("xpath=ancestor::*[self::div or self::section or self::article][1]"); + if (trial.count() == 0) { + trial = page.locator("div, section, article, li") + .filter(new Locator.FilterOptions().setHasText("Trial")) + .first(); + } + return trial; + } + + public String getPriceFromCard(Locator card) { + String text = card.innerText(); + java.util.regex.Matcher matcher = java.util.regex.Pattern + .compile("\\d+[\\d\\s\\u00A0]*\\s*(₽|руб)") + .matcher(text); + if (matcher.find()) { + return matcher.group().trim(); + } + matcher = java.util.regex.Pattern.compile("\\d+[\\d\\s\\u00A0]*").matcher(text); + if (matcher.find()) { + return matcher.group().trim(); + } + return ""; + } + + public String getDurationFromCard(Locator card) { + String text = card.innerText(); + java.util.regex.Matcher matcher = java.util.regex.Pattern + .compile("(\\d+)\\s+месяц") + .matcher(text); + if (matcher.find()) { + return matcher.group().trim(); + } + return ""; + } + + public void selectTrialOnSubscription(Locator trialCard) { + Locator button = trialCard.locator("a:visible:has-text('Оформить подписку')," + + " button:visible:has-text('Оформить подписку')").first(); + if (button.count() == 0) { + button = trialCard.getByText("Trial").first(); + } + button.click(new Locator.ClickOptions().setForce(true)); + } + + public Page clickBuy(Locator tile) { + Page popup; + tile.scrollIntoViewIfNeeded(); + Locator buy = tile.locator("a:visible:has-text('Купить'), button:visible:has-text('Купить')") + .first(); + if (buy.count() == 0) { + buy = tile.locator("a:visible[href*='pay'], a:visible[href*='checkout']," + + " a:visible[href*='order'], a:visible[href*='subscribe']," + + " button:visible[href*='pay']").first(); + } + if (buy.count() == 0) { + buy = page.locator("a:visible:has-text('Купить'), button:visible:has-text('Купить')") + .first(); + } + Page result = clickWithNavigation(buy); + if (!result.url().contains("/subscription")) { + return result; + } + + Locator trial = page.locator("section, div").filter( + new Locator.FilterOptions().setHasText("Trial") + ).first(); + Locator trialButton = trial.locator("a:visible:has-text('Оформить подписку')," + + " button:visible:has-text('Оформить подписку')").first(); + if (trialButton.count() > 0) { + result = clickWithNavigation(trialButton); + } + return result; + } + + private Page clickWithNavigation(Locator button) { + Page popup; + try { + popup = page.waitForPopup( + new Page.WaitForPopupOptions().setTimeout(3000), + button::click + ); + } catch (RuntimeException ex) { + popup = null; + } + if (popup != null) { + popup.waitForLoadState(); + return popup; + } + String before = page.url(); + try { + button.click(new Locator.ClickOptions().setForce(true).setTimeout(5000)); + } catch (RuntimeException ex) { + button.click(new Locator.ClickOptions().setForce(true).setTimeout(5000)); + } + for (int i = 0; i < 8; i++) { + if (!page.url().equals(before)) { + page.waitForLoadState(); + return page; + } + page.waitForTimeout(1000); + } + return page; + } +} diff --git a/src/test/java/ru/kovbasa/playwright/PlaywrightExtension.java b/src/test/java/ru/kovbasa/playwright/PlaywrightExtension.java new file mode 100644 index 0000000..875e1b1 --- /dev/null +++ b/src/test/java/ru/kovbasa/playwright/PlaywrightExtension.java @@ -0,0 +1,81 @@ +package ru.kovbasa.playwright; + +import com.google.inject.Injector; +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.BrowserType; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import com.microsoft.playwright.Tracing; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import ru.kovbasa.config.InjectorProvider; + +public class PlaywrightExtension implements BeforeEachCallback, AfterEachCallback { + + private static final String TRACE_DIR = "traces"; + + @Override + public void beforeEach(ExtensionContext context) throws Exception { + Injector injector = InjectorProvider.getInjector(); + TestConfig config = injector.getInstance(TestConfig.class); + TestResources resources = injector.getInstance(TestResources.class); + + Playwright playwright = Playwright.create(); + BrowserType browserType = resolveBrowserType(playwright, config.getBrowser()); + Browser browser = browserType.launch(new BrowserType.LaunchOptions() + .setHeadless(config.isHeadless()) + .setSlowMo(config.getSlowMo())); + BrowserContext browserContext = browser.newContext(); + Page page = browserContext.newPage(); + + page.setDefaultTimeout(config.getTimeoutMs()); + page.setDefaultNavigationTimeout(config.getTimeoutMs()); + page.setViewportSize(1920, 1080); + + browserContext.tracing().start(new Tracing.StartOptions() + .setScreenshots(true) + .setSnapshots(true) + .setSources(true)); + + resources.setPlaywright(playwright); + resources.setBrowser(browser); + resources.setContext(browserContext); + resources.setPage(page); + + Files.createDirectories(Path.of(TRACE_DIR)); + } + + @Override + public void afterEach(ExtensionContext context) throws Exception { + Injector injector = InjectorProvider.getInjector(); + TestResources resources = injector.getInstance(TestResources.class); + BrowserContext browserContext = resources.getContext(); + + if (browserContext != null) { + Path tracePath = Path.of(TRACE_DIR, buildTraceFileName(context)); + browserContext.tracing().stop(new Tracing.StopOptions().setPath(tracePath)); + } + + resources.clear(); + } + + private BrowserType resolveBrowserType(Playwright playwright, String browserName) { + String normalized = browserName.toLowerCase(Locale.ROOT); + return switch (normalized) { + case "firefox" -> playwright.firefox(); + case "webkit" -> playwright.webkit(); + default -> playwright.chromium(); + }; + } + + private String buildTraceFileName(ExtensionContext context) { + String raw = context.getDisplayName(); + String sanitized = raw.replaceAll("[^a-zA-Z0-9._-]", "_"); + return sanitized + ".zip"; + } +} diff --git a/src/test/java/ru/kovbasa/playwright/PlaywrightPageProvider.java b/src/test/java/ru/kovbasa/playwright/PlaywrightPageProvider.java new file mode 100644 index 0000000..444f0a3 --- /dev/null +++ b/src/test/java/ru/kovbasa/playwright/PlaywrightPageProvider.java @@ -0,0 +1,20 @@ +package ru.kovbasa.playwright; + +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.microsoft.playwright.Page; + +public class PlaywrightPageProvider implements Provider { + + private final TestResources resources; + + @Inject + public PlaywrightPageProvider(TestResources resources) { + this.resources = resources; + } + + @Override + public Page get() { + return resources.getPage(); + } +} diff --git a/src/test/java/ru/kovbasa/playwright/TestConfig.java b/src/test/java/ru/kovbasa/playwright/TestConfig.java new file mode 100644 index 0000000..23f873f --- /dev/null +++ b/src/test/java/ru/kovbasa/playwright/TestConfig.java @@ -0,0 +1,38 @@ +package ru.kovbasa.playwright; + +public class TestConfig { + + private final String baseUrl; + private final boolean headless; + private final double slowMo; + private final double timeoutMs; + private final String browser; + + public TestConfig() { + this.baseUrl = System.getProperty("baseUrl", "https://otus.ru"); + this.headless = Boolean.parseBoolean(System.getProperty("headless", "true")); + this.slowMo = Double.parseDouble(System.getProperty("slowMo", "0")); + this.timeoutMs = Double.parseDouble(System.getProperty("timeoutMs", "40000")); + this.browser = System.getProperty("browser", "chromium"); + } + + public String getBaseUrl() { + return baseUrl; + } + + public boolean isHeadless() { + return headless; + } + + public double getSlowMo() { + return slowMo; + } + + public double getTimeoutMs() { + return timeoutMs; + } + + public String getBrowser() { + return browser; + } +} diff --git a/src/test/java/ru/kovbasa/playwright/TestResources.java b/src/test/java/ru/kovbasa/playwright/TestResources.java new file mode 100644 index 0000000..af3218e --- /dev/null +++ b/src/test/java/ru/kovbasa/playwright/TestResources.java @@ -0,0 +1,65 @@ +package ru.kovbasa.playwright; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; + +public class TestResources { + + private Playwright playwright; + private Browser browser; + private BrowserContext context; + private Page page; + + public Playwright getPlaywright() { + return playwright; + } + + public void setPlaywright(Playwright playwright) { + this.playwright = playwright; + } + + public Browser getBrowser() { + return browser; + } + + public void setBrowser(Browser browser) { + this.browser = browser; + } + + public BrowserContext getContext() { + return context; + } + + public void setContext(BrowserContext context) { + this.context = context; + } + + public Page getPage() { + return page; + } + + public void setPage(Page page) { + this.page = page; + } + + public void clear() { + if (page != null) { + page.close(); + } + if (context != null) { + context.close(); + } + if (browser != null) { + browser.close(); + } + if (playwright != null) { + playwright.close(); + } + page = null; + context = null; + browser = null; + playwright = null; + } +} diff --git a/src/test/java/ru/kovbasa/tests/BaseTest.java b/src/test/java/ru/kovbasa/tests/BaseTest.java new file mode 100644 index 0000000..30a11c8 --- /dev/null +++ b/src/test/java/ru/kovbasa/tests/BaseTest.java @@ -0,0 +1,23 @@ +package ru.kovbasa.tests; + +import com.google.inject.Inject; +import org.junit.jupiter.api.extension.ExtendWith; +import ru.kovbasa.config.GuiceExtension; +import ru.kovbasa.playwright.PlaywrightExtension; +import ru.kovbasa.playwright.TestConfig; +import ru.kovbasa.playwright.TestResources; +import com.microsoft.playwright.Page; + +@ExtendWith({GuiceExtension.class, PlaywrightExtension.class}) +public abstract class BaseTest { + + @Inject + protected TestResources resources; + + @Inject + protected TestConfig config; + + protected Page page() { + return resources.getPage(); + } +} diff --git a/src/test/java/ru/kovbasa/tests/CatalogFiltersTest.java b/src/test/java/ru/kovbasa/tests/CatalogFiltersTest.java new file mode 100644 index 0000000..0c1b51d --- /dev/null +++ b/src/test/java/ru/kovbasa/tests/CatalogFiltersTest.java @@ -0,0 +1,49 @@ +package ru.kovbasa.tests; + +import org.junit.jupiter.api.Test; +import ru.kovbasa.pages.CatalogPage; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class CatalogFiltersTest extends BaseTest { + + @Test + void catalogFilters() { + CatalogPage catalog = new CatalogPage(page(), config); + catalog.open(); + + assertTrue(catalog.isDefaultDirectionSelected(), + "Default direction should be selected"); + assertTrue(catalog.isDefaultLevelSelected(), + "Default level should be selected"); + + var titlesBefore = catalog.getCourseTitles(5); + + catalog.setDurationRange(3, 10); + + var durations = catalog.getVisibleCourseDurations(); + assertFalse(durations.isEmpty(), "Durations should be present"); + for (Integer duration : durations) { + assertTrue(duration > 0, "Each course should have a duration"); + assertTrue(duration >= 3 && duration <= 10, + "Duration should be within selected range"); + } + + catalog.selectDirection("Архитектура"); + var titlesAfterDirection = catalog.getCourseTitles(5); + assertNotEquals(titlesBefore, titlesAfterDirection, + "Course cards should change after selecting direction"); + assertTrue(catalog.isDirectionSelected("Архитектура"), + "Selected direction should be visible in filter"); + + catalog.resetFilters(); + page().reload(); + assertTrue(catalog.isDefaultDirectionSelected(), + "Direction should be reset to default"); + var titlesAfterReset = catalog.getCourseTitles(5); + assertNotEquals(titlesAfterDirection, titlesAfterReset, + "Course cards should change after reset"); + } +} diff --git a/src/test/java/ru/kovbasa/tests/ClickhouseTeachersTest.java b/src/test/java/ru/kovbasa/tests/ClickhouseTeachersTest.java new file mode 100644 index 0000000..b674cb4 --- /dev/null +++ b/src/test/java/ru/kovbasa/tests/ClickhouseTeachersTest.java @@ -0,0 +1,77 @@ +package ru.kovbasa.tests; + +import com.microsoft.playwright.Locator; +import org.junit.jupiter.api.Test; +import ru.kovbasa.pages.ClickhousePage; + +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ClickhouseTeachersTest extends BaseTest { + + @Test + void teachersCarouselAndPopup() { + ClickhousePage clickhouse = new ClickhousePage(page(), config); + clickhouse.open(); + + Locator cards = clickhouse.getTeacherCards(); + cards.first().waitFor(); + assertTrue(cards.count() > 0, "Teacher cards should be visible"); + + 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, + "Teacher list should be scrolled after drag"); + + clickhouse.openTeacherPopup(activeCard); + String popupName = clickhouse.getPopupTeacherName(); + assertTrue(!popupName.isEmpty() + && (popupName.contains(firstNameAfter) + || firstNameAfter.contains(popupName)), + "Popup should match clicked teacher"); + + clickhouse.clickPopupNext(); + String nextName = clickhouse.getPopupTeacherName(); + if (nextName.equals(popupName)) { + clickhouse.dragTeachersCarousel(); + nextName = clickhouse.getPopupTeacherName(); + } + assertNotEquals(popupName, nextName, "Next teacher should differ"); + + clickhouse.clickPopupPrev(); + String prevName = clickhouse.getPopupTeacherName(); + int attempts = 0; + while (!matches(prevName, popupName) && attempts < 2) { + clickhouse.clickPopupPrev(); + prevName = clickhouse.getPopupTeacherName(); + attempts++; + } + assertTrue(matches(prevName, popupName), + "Previous teacher should be opened"); + } + + private String getCardName(Locator card) { + card.scrollIntoViewIfNeeded(); + Locator name = card.locator("h3, h4, [class*='name']").first(); + if (name.count() > 0) { + String text = name.textContent(); + return text == null ? "" : text.trim(); + } + String text = card.textContent(); + return text == null ? "" : text.trim(); + } + + private boolean matches(String a, String b) { + return a.contains(b) || b.contains(a); + } +} diff --git a/src/test/java/ru/kovbasa/tests/CorporateServicesTest.java b/src/test/java/ru/kovbasa/tests/CorporateServicesTest.java new file mode 100644 index 0000000..b4f6ee1 --- /dev/null +++ b/src/test/java/ru/kovbasa/tests/CorporateServicesTest.java @@ -0,0 +1,62 @@ +package ru.kovbasa.tests; + +import com.microsoft.playwright.Page; +import java.util.List; +import org.junit.jupiter.api.Test; +import ru.kovbasa.pages.CatalogPage; +import ru.kovbasa.pages.CorporatePage; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class CorporateServicesTest extends BaseTest { + + @Test + void corporateServicesNavigation() { + CorporatePage corporate = new CorporatePage(page(), config); + corporate.open(); + + var targetPage = corporate.clickLearnMoreInNeedCourseBlock(); + CorporatePage businessPage = targetPage == page() ? corporate + : new CorporatePage(targetPage, config); + assertTrue(businessPage.isBusinessCoursePageOpened(), + "Business course development page should be opened"); + + List directions = businessPage.getDirections().stream() + .map(String::trim) + .filter(text -> !text.isBlank()) + .toList(); + assertFalse(directions.isEmpty(), "Directions should be displayed"); + + CorporatePage.DirectionClick directionClick = businessPage.clickFirstDirection(); + String clickedDirection = directionClick.value(); + Page catalogPage = directionClick.page(); + + boolean catalogOpened = catalogPage.url().contains("/catalog/courses") + || catalogPage.getByText("Каталог").first().isVisible(); + assertTrue(catalogOpened, "Catalog should be opened after direction click"); + + CatalogPage catalog = new CatalogPage(catalogPage, config); + String expectedCategory = extractCategoryParam(clickedDirection); + if (expectedCategory != null) { + assertTrue(catalogPage.url().contains("categories=" + expectedCategory), + "Catalog should open with selected category from direction link"); + } else { + boolean directionSelected = catalog.isDirectionSelected(clickedDirection); + assertTrue(directionSelected, "Clicked direction should be selected in catalog"); + } + } + + private String extractCategoryParam(String linkOrText) { + if (linkOrText == null) { + return null; + } + int idx = linkOrText.indexOf("categories="); + if (idx < 0) { + return null; + } + String tail = linkOrText.substring(idx + "categories=".length()); + int amp = tail.indexOf('&'); + return amp >= 0 ? tail.substring(0, amp) : tail; + } +} diff --git a/src/test/java/ru/kovbasa/tests/SubscriptionTest.java b/src/test/java/ru/kovbasa/tests/SubscriptionTest.java new file mode 100644 index 0000000..0178ef6 --- /dev/null +++ b/src/test/java/ru/kovbasa/tests/SubscriptionTest.java @@ -0,0 +1,70 @@ +package ru.kovbasa.tests; + +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import org.junit.jupiter.api.Test; +import ru.kovbasa.pages.PaymentPage; +import ru.kovbasa.pages.SubscriptionPage; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class SubscriptionTest extends BaseTest { + + @Test + void subscriptionFlow() { + SubscriptionPage subscription = new SubscriptionPage(page(), config); + subscription.open(); + + Locator tiles = subscription.getSubscriptionTiles(); + tiles.first().waitFor(); + assertTrue(tiles.count() > 0, "Subscription tiles should be visible"); + + Locator tile = tiles.first(); + + subscription.expandDetails(tile); + assertTrue(subscription.isDetailsExpanded(tile), + "Details should expand after clicking 'Подробнее'"); + + subscription.collapseDetails(tile); + assertFalse(subscription.isDetailsExpanded(tile), + "Details should collapse after clicking 'Свернуть'"); + + Page paymentPage = subscription.clickBuy(tile); + if (paymentPage.url().contains("/subscription")) { + Locator trialCard = subscription.getTrialCard(); + trialCard.waitFor(); + String priceBefore = subscription.getPriceFromCard(tile); + String durationBefore = subscription.getDurationFromCard(tile); + + subscription.selectTrialOnSubscription(trialCard); + + String priceAfter = subscription.getPriceFromCard(trialCard); + String durationAfter = subscription.getDurationFromCard(trialCard); + + assertNotEquals(priceBefore, priceAfter, "Price should change for Trial"); + assertNotEquals(durationBefore, durationAfter, + "Duration should change for Trial"); + return; + } + + PaymentPage payment = new PaymentPage(paymentPage); + assertTrue(payment.isOpened(), "Payment page should be opened"); + + assertTrue(payment.selectFirstNonTrial(), + "Non-trial plan should be selectable"); + String priceBefore = payment.getPriceText(); + String durationBefore = payment.getDurationText(); + + payment.selectTrial(); + payment.waitForPlanChange(priceBefore, durationBefore); + + String priceAfter = payment.getPriceText(); + String durationAfter = payment.getDurationText(); + + assertNotEquals(priceBefore, priceAfter, "Price should change for Trial"); + assertNotEquals(durationBefore, durationAfter, + "Duration should change for Trial"); + } +} diff --git a/src/test/java/ru/kovbasa/utils/UiActions.java b/src/test/java/ru/kovbasa/utils/UiActions.java new file mode 100644 index 0000000..cd90ea9 --- /dev/null +++ b/src/test/java/ru/kovbasa/utils/UiActions.java @@ -0,0 +1,31 @@ +package ru.kovbasa.utils; + +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import java.util.List; + +public final class UiActions { + + private UiActions() { + } + + public static void closeCommonPopups(Page page) { + List selectors = List.of( + "button:has-text('Принять')", + "button:has-text('Согласен')", + "button:has-text('Ок')", + "button:has-text('OK')", + "button[aria-label='Закрыть']", + "button[aria-label='Close']", + ".popup__close", + ".modal__close" + ); + + for (String selector : selectors) { + Locator locator = page.locator(selector).first(); + if (locator.isVisible()) { + locator.click(); + } + } + } +} diff --git a/traces.zip b/traces.zip new file mode 100644 index 0000000..8dd1296 Binary files /dev/null and b/traces.zip differ