hw4: finalize selenoid and ansible workflow with citrus tests

This commit is contained in:
2026-02-27 01:38:06 +03:00
parent c06e9a89f1
commit 7ddea2e997
36 changed files with 1171 additions and 122 deletions

View File

@@ -1,16 +1,30 @@
package ru.kovbasa.config;
import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import com.google.inject.Singleton;
import ru.kovbasa.driver.ChromeDriverFactory;
import ru.kovbasa.driver.DriverFactory;
import ru.kovbasa.driver.MobileChromeDriverFactory;
import ru.kovbasa.driver.RemoteDriverFactory;
import ru.kovbasa.driver.WebDriverProvider;
public class DriverModule extends AbstractModule {
@Override
protected void configure() {
bind(DriverFactory.class).to(ChromeDriverFactory.class).in(Singleton.class);
bind(WebDriverProvider.class).in(Singleton.class);
}
@Provides
@Singleton
public DriverFactory provideDriverFactory() {
if (TestConfig.isSelenoidMode()) {
if (TestConfig.isMobileBrowser()) {
return new MobileChromeDriverFactory();
}
return new RemoteDriverFactory();
}
return new ChromeDriverFactory();
}
}

View File

@@ -8,6 +8,19 @@ public final class TestConfig {
private static final String COURSE_NAME =
System.getProperty("course.name", "Python Developer");
private static final String EXECUTION_MODE =
System.getProperty("execution.mode", "local");
private static final String BROWSER =
System.getProperty("browser", "chrome");
private static final String BROWSER_VERSION =
System.getProperty("browser.version", "");
private static final String SELENOID_URL =
System.getProperty("selenoid.url", "http://localhost/wd/hub");
private TestConfig() {
}
@@ -18,4 +31,32 @@ public final class TestConfig {
public static String getCourseName() {
return COURSE_NAME;
}
public static String getExecutionMode() {
return EXECUTION_MODE;
}
public static String getBrowser() {
return BROWSER;
}
public static String getSelenoidUrl() {
return SELENOID_URL;
}
public static String getBrowserVersion() {
return BROWSER_VERSION;
}
public static boolean isSelenoidMode() {
return "selenoid".equalsIgnoreCase(EXECUTION_MODE);
}
public static boolean isMobileBrowser() {
return "mobile_chrome".equalsIgnoreCase(BROWSER);
}
public static boolean isFirefoxBrowser() {
return "firefox".equalsIgnoreCase(BROWSER);
}
}

View File

@@ -0,0 +1,20 @@
package ru.kovbasa.driver;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import java.util.Map;
public class MobileChromeDriverFactory extends RemoteDriverFactory {
@Override
public WebDriver createDriver() {
final ChromeOptions options = new ChromeOptions();
options.setCapability("browserName", "chrome");
applyCommonCapabilities(options);
options.setCapability("goog:chromeOptions", Map.of(
"mobileEmulation", Map.of("deviceName", "Pixel 7")
));
return createRemote(options);
}
}

View File

@@ -0,0 +1,60 @@
package ru.kovbasa.driver;
import org.openqa.selenium.MutableCapabilities;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.firefox.FirefoxOptions;
import org.openqa.selenium.remote.RemoteWebDriver;
import ru.kovbasa.config.TestConfig;
import java.net.MalformedURLException;
import java.net.URI;
import java.util.List;
import java.util.Map;
public class RemoteDriverFactory implements DriverFactory {
@Override
public WebDriver createDriver() {
final MutableCapabilities options = TestConfig.isFirefoxBrowser()
? buildFirefoxOptions()
: buildChromeOptions();
return createRemote(options);
}
protected MutableCapabilities buildChromeOptions() {
final ChromeOptions options = new ChromeOptions();
options.addArguments("--disable-notifications");
applyCommonCapabilities(options);
return options;
}
protected MutableCapabilities buildFirefoxOptions() {
final FirefoxOptions options = new FirefoxOptions();
applyCommonCapabilities(options);
return options;
}
protected void applyCommonCapabilities(MutableCapabilities options) {
final String browserVersion = TestConfig.getBrowserVersion();
if (!browserVersion.isBlank()) {
options.setCapability("browserVersion", browserVersion);
}
options.setCapability("selenoid:options", Map.of(
"name", "otus-ui-tests",
"sessionTimeout", "15m",
"env", List.of("TZ=UTC"),
"labels", Map.of("manual", "true"),
"enableVNC", true,
"enableVideo", false
));
}
protected WebDriver createRemote(MutableCapabilities capabilities) {
try {
return new RemoteWebDriver(URI.create(TestConfig.getSelenoidUrl()).toURL(), capabilities);
} catch (MalformedURLException e) {
throw new IllegalStateException("Invalid selenoid.url: " + TestConfig.getSelenoidUrl(), e);
}
}
}

View File

@@ -5,6 +5,7 @@ import org.openqa.selenium.WebDriver;
import org.openqa.selenium.support.events.EventFiringDecorator;
import io.github.bonigarcia.wdm.WebDriverManager;
import ru.kovbasa.config.TestConfig;
import ru.kovbasa.listeners.HighlightElementListener;
public final class WebDriverProvider {
@@ -12,10 +13,6 @@ public final class WebDriverProvider {
private WebDriver driver;
private final DriverFactory driverFactory;
static {
WebDriverManager.chromedriver().setup();
}
@Inject
public WebDriverProvider(DriverFactory driverFactory) {
this.driverFactory = driverFactory;
@@ -29,6 +26,9 @@ public final class WebDriverProvider {
}
private WebDriver createDecoratedDriver() {
if (!TestConfig.isSelenoidMode()) {
WebDriverManager.chromedriver().setup();
}
final WebDriver raw = driverFactory.createDriver();
return new EventFiringDecorator(new HighlightElementListener())
.decorate(raw);

View File

@@ -3,13 +3,14 @@ package ru.kovbasa.pages;
import com.google.inject.Inject;
import java.time.Duration;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import org.openqa.selenium.By;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.StaleElementReferenceException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.interactions.Actions;
@@ -35,6 +36,8 @@ public class CatalogPage {
public CatalogPage open() {
driver.get(TestConfig.getBaseUrl() + "/catalog/courses");
final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(20));
wait.until((ExpectedCondition<Boolean>) drv -> !drv.findElements(courseCards).isEmpty());
return this;
}
@@ -67,22 +70,38 @@ public class CatalogPage {
}
public List<CourseCard> getAllCourseCards() {
final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(20));
wait.until((ExpectedCondition<Boolean>) drv -> !drv.findElements(courseCards).isEmpty());
return driver.findElements(courseCards).stream()
.map(CourseCard::new)
.toList();
}
public List<CourseItem> getAllCourses() {
return getAllCourseCards().stream()
.map(card -> {
try {
return new CourseItem(card.title(), card.startDate());
} catch (RuntimeException e) {
return null;
final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(20));
wait.until((ExpectedCondition<Boolean>) drv -> !drv.findElements(courseCards).isEmpty());
for (int attempt = 1; attempt <= 3; attempt++) {
try {
final List<CourseItem> parsedCourses = new ArrayList<>();
for (WebElement cardElement : driver.findElements(courseCards)) {
final CourseItem item = toCourseItem(new CourseCard(cardElement));
if (item != null) {
parsedCourses.add(item);
}
})
.filter(Objects::nonNull)
.toList();
}
if (!parsedCourses.isEmpty()) {
return parsedCourses;
}
} catch (StaleElementReferenceException ignored) {
// Dynamic catalog can re-render the card list; retry with fresh references.
}
}
throw new IllegalStateException(
"No parsable courses found in catalog after retries. Check course card/date locators."
);
}
public List<CourseItem> findEarliestCourses() {
@@ -90,8 +109,9 @@ public class CatalogPage {
final LocalDate minDate = all.stream()
.map(CourseItem::startDate)
.reduce((left, right) -> left.isBefore(right) ? left : right)
.orElseThrow();
.min(LocalDate::compareTo)
.orElseThrow(() -> new IllegalStateException(
"Unable to determine earliest date from parsed courses"));
return all.stream()
.filter(c -> c.startDate().isEqual(minDate))
@@ -104,8 +124,9 @@ public class CatalogPage {
final LocalDate maxDate = all.stream()
.map(CourseItem::startDate)
.reduce((left, right) -> left.isAfter(right) ? left : right)
.orElseThrow();
.max(LocalDate::compareTo)
.orElseThrow(() -> new IllegalStateException(
"Unable to determine latest date from parsed courses"));
return all.stream()
.filter(c -> c.startDate().isEqual(maxDate))
@@ -116,23 +137,45 @@ public class CatalogPage {
public CoursePage openCourse(CourseItem course) {
PageUtils.removeBottomBanner(driver);
final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(12));
wait.until((ExpectedCondition<Boolean>) drv -> !drv.findElements(courseCards).isEmpty());
final WebElement card = wait.until(drv ->
drv.findElements(courseCards).stream()
.filter(c -> new CourseCard(c).title().equals(course.title()))
.findFirst()
.orElse(null)
);
for (int attempt = 1; attempt <= 5; attempt++) {
try {
for (WebElement card : driver.findElements(courseCards)) {
final CourseCard courseCard = new CourseCard(card);
final String candidateTitle;
final LocalDate candidateDate;
try {
candidateTitle = courseCard.title();
candidateDate = courseCard.startDate();
} catch (RuntimeException ignored) {
continue;
}
if (!candidateTitle.equals(course.title()) || !candidateDate.equals(course.startDate())) {
continue;
}
((JavascriptExecutor) driver)
.executeScript("arguments[0].scrollIntoView({block:'center'});", card);
((JavascriptExecutor) driver)
.executeScript("arguments[0].scrollIntoView({block:'center'});", card);
new Actions(driver)
.moveToElement(card)
.perform();
card.click();
new Actions(driver).moveToElement(card).perform();
card.click();
return new CoursePage(driver);
}
} catch (StaleElementReferenceException ignored) {
// Re-query cards when DOM updates during search/click.
}
}
return new CoursePage(driver);
throw new NoSuchElementException("Course card not found for title: " + course.title());
}
private CourseItem toCourseItem(CourseCard card) {
try {
return new CourseItem(card.title(), card.startDate());
} catch (RuntimeException e) {
return null;
}
}
}

View File

@@ -4,6 +4,7 @@ import com.google.inject.Inject;
import org.openqa.selenium.By;
import org.openqa.selenium.ElementClickInterceptedException;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.interactions.Actions;
@@ -20,7 +21,8 @@ public class MainPage {
private final WebDriver driver;
private final By menuLearning = By.cssSelector("span[title='Обучение']");
private final By menuLearningByTitle = By.cssSelector("span[title='Обучение']");
private final By menuLearningByText = By.xpath("//span[normalize-space()='Обучение']");
private final By categories = By.cssSelector("a[href*='/categories/']");
@Inject
@@ -36,17 +38,18 @@ public class MainPage {
public String clickRandomCategory() {
PageUtils.removeBottomBanner(driver);
final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(20));
final WebElement menu = wait.until(drv -> drv.findElement(menuLearning));
menu.click();
final List<WebElement> els = wait.until(drv -> {
final List<WebElement> found = drv.findElements(categories).stream()
.filter(WebElement::isDisplayed)
.toList();
return found.isEmpty() ? null : found;
});
List<WebElement> els = findVisibleCategories(wait);
if (els.isEmpty()) {
clickLearningMenu(wait);
els = wait.until(drv -> {
final List<WebElement> found = drv.findElements(categories).stream()
.filter(WebElement::isDisplayed)
.toList();
return found.isEmpty() ? null : found;
});
}
final WebElement chosen = els.get(ThreadLocalRandom.current().nextInt(els.size()));
final String href = chosen.getAttribute("href");
@@ -68,4 +71,29 @@ public class MainPage {
return href;
}
private List<WebElement> findVisibleCategories(WebDriverWait wait) {
return wait.until(drv -> drv.findElements(categories).stream()
.filter(WebElement::isDisplayed)
.toList());
}
private void clickLearningMenu(WebDriverWait wait) {
WebElement menu = null;
try {
menu = wait.until(drv -> drv.findElement(menuLearningByTitle));
} catch (NoSuchElementException ignored) {
} catch (org.openqa.selenium.TimeoutException ignored) {
}
if (menu == null) {
menu = wait.until(drv -> drv.findElement(menuLearningByText));
}
try {
menu.click();
} catch (ElementClickInterceptedException ignored) {
((JavascriptExecutor) driver).executeScript("arguments[0].click();", menu);
}
}
}