Add Playwright UI tests for homework 6

This commit is contained in:
2026-03-17 23:08:59 +03:00
commit 46ddc4dd87
25 changed files with 1941 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.idea/
*.iml
.target/
target/
playwright-report/
traces/

82
README.md Normal file
View File

@@ -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` с доступом к сети.
- Если попапы мешают кликам, используется автоматическое закрытие типовых модалок.

53
checkstyle.xml Normal file
View File

@@ -0,0 +1,53 @@
<?xml version="1.0"?>
<!DOCTYPE module PUBLIC
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
"https://checkstyle.org/dtds/configuration_1_3.dtd">
<module name="Checker">
<!-- File encoding -->
<property name="charset" value="UTF-8"/>
<!-- Fail build on any violation -->
<property name="severity" value="error"/>
<!-- Disallow tabs -->
<module name="FileTabCharacter"/>
<!-- Max line length -->
<module name="LineLength">
<property name="max" value="120"/>
</module>
<module name="TreeWalker">
<!-- Naming conventions -->
<module name="TypeName"/>
<module name="MethodName"/>
<module name="ParameterName"/>
<module name="LocalVariableName"/>
<module name="MemberName"/>
<!-- Imports -->
<module name="AvoidStarImport"/>
<module name="UnusedImports"/>
<module name="RedundantImport"/>
<!-- Braces -->
<module name="NeedBraces"/>
<module name="LeftCurly"/>
<module name="RightCurly"/>
<!-- Whitespace -->
<module name="WhitespaceAround"/>
<module name="WhitespaceAfter"/>
<!-- Empty blocks are forbidden -->
<module name="EmptyBlock"/>
<!-- Enforce final for local variables when possible -->
<module name="FinalLocalVariable"/>
</module>
</module>

134
pom.xml Normal file
View File

@@ -0,0 +1,134 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>ru.kovbasa</groupId>
<artifactId>homework_6</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<java.version>21</java.version>
<playwright.version>1.58.0</playwright.version>
<junit.version>5.10.1</junit.version>
<guice.version>7.0.0</guice.version>
<slf4j.version>2.0.11</slf4j.version>
<logback.version>1.4.14</logback.version>
<maven.compiler.version>3.11.0</maven.compiler.version>
<surefire.version>3.2.3</surefire.version>
<checkstyle.plugin.version>3.3.1</checkstyle.plugin.version>
<spotbugs.plugin.version>4.8.3.0</spotbugs.plugin.version>
<spotbugs.version>4.8.3</spotbugs.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>${playwright.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.google.inject</groupId>
<artifactId>guice</artifactId>
<version>${guice.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>${logback.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${maven.compiler.version}</version>
<configuration>
<release>${java.version}</release>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>${surefire.version}</version>
<configuration>
<useModulePath>false</useModulePath>
<argLine>-Dcom.google.common.util.concurrent.AbstractFuture.disableUnsafe=true</argLine>
<systemPropertyVariables>
<playwright.selectors.setTestIdAttribute>data-qa</playwright.selectors.setTestIdAttribute>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>${checkstyle.plugin.version}</version>
<executions>
<execution>
<id>checkstyle-validation</id>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
<configuration>
<configLocation>checkstyle.xml</configLocation>
<consoleOutput>true</consoleOutput>
<failsOnError>true</failsOnError>
</configuration>
</plugin>
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
<version>${spotbugs.plugin.version}</version>
<dependencies>
<dependency>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs</artifactId>
<version>${spotbugs.version}</version>
</dependency>
</dependencies>
<executions>
<execution>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
<configuration>
<excludeFilterFile>spotbugs-exclude.xml</excludeFilterFile>
</configuration>
</plugin>
</plugins>
</build>
</project>

9
spotbugs-exclude.xml Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<FindBugsFilter>
<!-- Ignore warnings in test sources -->
<Match>
<Source name="~.*src/test/java/.*"/>
</Match>
</FindBugsFilter>

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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<String> getCourseTitles(int limit) {
Locator cards = getCourseCards();
Locator titles = cards.locator("h4, h5, h6");
titles.first().waitFor();
List<String> 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<Integer> getVisibleCourseDurations() {
List<Integer> 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<String, Object> trackBox = (Map<String, Object>) 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});
}
}

View File

@@ -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<Double> 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<Double> 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<Double> 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<Double> 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());
}
}

View File

@@ -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<String> 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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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";
}
}

View File

@@ -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<Page> {
private final TestResources resources;
@Inject
public PlaywrightPageProvider(TestResources resources) {
this.resources = resources;
}
@Override
public Page get() {
return resources.getPage();
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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<String> 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;
}
}

View File

@@ -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");
}
}

View File

@@ -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<String> 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();
}
}
}
}

BIN
traces.zip Normal file

Binary file not shown.