Compare commits

3 Commits

36 changed files with 1207 additions and 127 deletions

199
README.md
View File

@@ -1,94 +1,149 @@
# OTUS Selenium Homework 1
# OTUS Homework 4: Selenoid + GGR + Nginx
## Цель проекта
Автоматизировать 3 UI-сценария на `https://otus.ru` с использованием Selenium WebDriver 4+, JUnit 5, Guice DI, listeners, Stream API, Jsoup и обязательных проверок качества (Checkstyle + SpotBugs).
В этом проекте я собрал инфраструктуру для запуска UI-тестов через Selenoid и добавил Ansible-деплой для локального стенда и удаленных VM.
## Стек технологий
- Java 21
- Maven
- Selenium `4.38.0`
- WebDriverManager `6.3.3`
- JUnit 5
- Guice
- Jsoup
- Checkstyle
- SpotBugs
## Что реализовано
- UI-тесты из HW1 запускаются через удаленный `RemoteWebDriver` (Selenoid).
- Поддержаны браузеры: `chrome`, `firefox`, `mobile_chrome`.
- Подняты сервисы: `selenoid`, `selenoid-ui`, `ggr`, `nginx`.
- Один inventory для всех стендов: `localhost` (WSL), `vm1`, `vm2`.
## Реализованные сценарии
1. Поиск курса по имени.
- Открытие каталога `https://otus.ru/catalog/courses`
- Поиск курса по имени через Stream API
- Клик по плитке курса
- Проверка заголовка открытого курса
Точки доступа:
- UI: `http://<host_ip>/`
- WebDriver: `http://<host_ip>/wd/hub`
2. Самые ранние/поздние курсы по дате старта.
- Открытие каталога `https://otus.ru/catalog/courses`
- Поиск ранних и поздних курсов через Stream API + `reduce`
- При совпадении дат проверяются все курсы с этой датой
- Проверка названия и даты старта на странице курса через Jsoup
## Версии в проекте
Настройки лежат в `ansible/inventory/group_vars/all.yml`.
3. Случайная категория с главной страницы.
- Открытие `https://otus.ru`
- Открытие меню «Обучение»
- Выбор случайной категории
- Проверка, что открыта корректная категория
Браузеры:
- Chrome: `128.0`, `127.0`
- Firefox: `125.0`, `124.0`
## Архитектура
- 2-уровневый тест-дизайн: `tests` + `page objects`
- DI через Guice для тестов и страниц
- JUnit 5 Extension (`GuiceExtension`), без базового класса-теста
- Фабрика драйвера:
- `DriverFactory` (интерфейс)
- `ChromeDriverFactory` (реализация)
- `WebDriverProvider` (жизненный цикл драйвера + декоратор listener)
- Подсветка через listener:
- Подсветка ставится в `beforeClick`
- Снимается в `afterClick`
- Стиль элемента возвращается в исходное состояние
Образы инфраструктуры:
- `aerokube/selenoid:1.11.3`
- `aerokube/selenoid-ui:1.10.11`
- `aerokube/ggr:1.7.2`
- `aerokube/ggr-ui:latest-release`
- `nginx:1.28.2`
## Структура проекта
- `src/main/java/ru/kovbasa/config` — DI-конфигурация
- `src/main/java/ru/kovbasa/driver` — фабрика и провайдер WebDriver
- `src/main/java/ru/kovbasa/listeners` — listener подсветки
- `src/main/java/ru/kovbasa/pages` — Page Object классы
- `src/main/java/ru/kovbasa/elements` — типизированные UI-элементы
- `src/test/java/ru/kovbasa/config` — JUnit extension для DI
- `src/test/java/ru/kovbasa/tests` — автотесты
## Что нужно подготовить перед запуском
На каждой VM должен быть пользователь `ansible`:
- вход по SSH-ключу;
- `sudo` без пароля (`NOPASSWD`).
## Требования к окружению
1. Установлен JDK 21 (доступен в `PATH`)
2. Установлен Google Chrome
3. Установлен Maven 3.9+
4. Есть доступ в интернет и к `otus.ru` (тесты запускаются на живом сайте)
Ниже один простой вариант первичной подготовки VM.
## Запуск
### 1. Запуск только тестов
1. На локальной машине генерирую ключи (если их еще нет):
```bash
mvn test
ssh-keygen -f ~/.ssh/id_ed25519
```
### 2. Полная проверка (тесты + Checkstyle + SpotBugs)
2. Захожу на VM под `root`:
```bash
mvn verify
ssh root@10.10.2.127
```
### 3. Запуск отдельного тестового класса
3. Под `root` создаю пользователя `ansible`, добавляю в `sudo` и в `sudoers`:
```bash
mvn "-Dtest=ru.kovbasa.tests.CourseSearchTest" test
useradd -m -s /bin/bash ansible
usermod -aG sudo ansible
echo "ansible ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/ansible
chmod 440 /etc/sudoers.d/ansible
```
## Параметры запуска
Пробрасываются через Maven Surefire:
- `base.url` (по умолчанию `https://otus.ru`)
- `course.name` (по умолчанию `Python Developer`)
Пример переопределения:
4. Под `root` кладу публичный ключ в профиль `ansible`:
```bash
mvn "-Dcourse.name=Python Developer" test
install -d -m 700 -o ansible -g ansible /home/ansible/.ssh
cat >> /home/ansible/.ssh/authorized_keys
```
После запуска этой команды вставляю содержимое `~/.ssh/id_ed25519.pub`, затем `Ctrl+D`.
5. Под `root` выставляю права:
```bash
chown ansible:ansible /home/ansible/.ssh/authorized_keys
chmod 600 /home/ansible/.ssh/authorized_keys
```
## Quality Gates
- Checkstyle и SpotBugs выполняется в фазе `verify`
6. Проверяю вход под `ansible`:
```bash
ssh -i ~/.ssh/id_ed25519 ansible@10.10.2.127 "whoami && sudo -n true && echo sudo_ok"
```
## Примечания
- Тесты зависят от текущей верстки/контента `otus.ru`.
## Как добавить новые VM
1. Добавляю хост в `ansible/inventory/hosts.ini`.
2. Создаю `ansible/inventory/host_vars/vmN.yml`:
```yaml
ansible_host: <ip>
ansible_user: ansible
ggr_selenoid_host_override: selenoid
```
## Запуск Ansible
В PowerShell синтаксис `VAR=value command` не работает, поэтому запускаю через `wsl bash -lc`.
Важно: `--limit` должен быть внутри кавычек команды.
### localhost (WSL)
```powershell
wsl bash -lc "cd /mnt/c/Users/spawn/IdeaProjects/otus-autotests/homework_1/ansible && ANSIBLE_ROLES_PATH=./roles ansible-playbook -i inventory/hosts.ini playbooks/site.yml --limit localhost"
```
### VM1
```powershell
wsl bash -lc "cd /mnt/c/Users/spawn/IdeaProjects/otus-autotests/homework_1/ansible && ANSIBLE_ROLES_PATH=./roles ansible-playbook -i inventory/hosts.ini playbooks/site.yml --limit vm1"
```
### VM2
```powershell
wsl bash -lc "cd /mnt/c/Users/spawn/IdeaProjects/otus-autotests/homework_1/ansible && ANSIBLE_ROLES_PATH=./roles ansible-playbook -i inventory/hosts.ini playbooks/site.yml --limit vm2"
```
Если ключ используется из `/mnt/c/...`, в WSL может быть ошибка `UNPROTECTED PRIVATE KEY FILE`.
Я использую копию ключа в WSL-профиле:
```bash
mkdir -p ~/.ssh
cp /mnt/c/Users/spawn/.ssh/id_ed25519 ~/.ssh/id_ed25519
cp /mnt/c/Users/spawn/.ssh/id_ed25519.pub ~/.ssh/id_ed25519.pub
chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_ed25519
chmod 644 ~/.ssh/id_ed25519.pub
```
## Команды для запуска тестов
### Все UI-тесты (Chrome)
```bash
mvn "-Dexecution.mode=selenoid" "-Dbrowser=chrome" "-Dbrowser.version=128.0" "-Dselenoid.url=http://10.10.2.112/wd/hub" test
```
### Конкретный UI-тест
```bash
mvn "-Dexecution.mode=selenoid" "-Dbrowser=chrome" "-Dbrowser.version=128.0" "-Dselenoid.url=http://10.10.2.112/wd/hub" "-Dtest=ru.kovbasa.tests.CourseSearchTest" test
```
### Mobile Chrome
```bash
mvn "-Dexecution.mode=selenoid" "-Dbrowser=mobile_chrome" "-Dbrowser.version=128.0" "-Dselenoid.url=http://10.10.2.112/wd/hub" test
```
### Firefox
```bash
mvn "-Dexecution.mode=selenoid" "-Dbrowser=firefox" "-Dbrowser.version=125.0" "-Dselenoid.url=http://10.10.2.112/wd/hub" test
```
### Citrus (HW3 API)
Citrus: вынесен в отдельный модуль `citrus-tests`, реализовано на базе HW3;
```bash
mvn -f citrus-tests/pom.xml test
```
### Запуск тестов через Ansible
```powershell
wsl bash -lc "cd /mnt/c/Users/spawn/IdeaProjects/otus-autotests/homework_1/ansible && ANSIBLE_ROLES_PATH=./roles ansible-playbook -i inventory/hosts.ini playbooks/site.yml --limit vm1 -e run_tests_via_ansible=true"
```
## Запуск с Linux/macOS
На Linux/macOS:
```bash
cd ansible
ANSIBLE_ROLES_PATH=./roles ansible-playbook -i inventory/hosts.ini playbooks/site.yml --limit vm1
```

5
ansible/ansible.cfg Normal file
View File

@@ -0,0 +1,5 @@
[defaults]
inventory = inventory/hosts.ini
host_key_checking = False
retry_files_enabled = False
interpreter_python = auto_silent

View File

@@ -0,0 +1,37 @@
---
run_apt_upgrade: false
ansible_ssh_private_key_file: /home/spawn/.ssh/id_ed25519
ansible_ssh_common_args: "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o ServerAliveInterval=10 -o ServerAliveCountMax=3"
selenoid_dir: /opt/selenoid
selenoid_compose_file: docker-compose.selenoid.yml
selenoid_limit: 2
selenoid_ui_port: 8080
selenoid_container_network: "selenoid_default"
selenoid_host_port: 4444
selenoid_image: "aerokube/selenoid:1.11.3"
selenoid_ui_image: "aerokube/selenoid-ui:1.10.11"
selenoid_chrome_versions:
- "128.0"
- "127.0"
selenoid_firefox_versions:
- "125.0"
- "124.0"
ggr_dir: /opt/ggr
ggr_compose_file: docker-compose.ggr.yml
ggr_gateway_port: 4445
ggr_ui_listen_port: 8888
ggr_ui_public_port: 8888
ggr_nginx_public_port: 80
ggr_quota_name: guest
ggr_image: "aerokube/ggr:1.7.2"
ggr_ui_image: "aerokube/ggr-ui:latest-release"
nginx_image: "nginx:1.28.2"
run_tests_via_ansible: false
test_runner_execute_mode: control
test_runner_project_dir: /opt/otus-autotests/homework_1
test_runner_control_project_dir: /mnt/c/Users/spawn/IdeaProjects/otus-autotests/homework_1
test_runner_ui_test_class: ru.kovbasa.tests.CourseSearchTest
test_runner_browser: chrome

View File

@@ -0,0 +1,3 @@
---
ansible_connection: local
ggr_selenoid_host_override: selenoid

View File

@@ -0,0 +1,4 @@
---
ansible_host: 10.10.2.112
ansible_user: ansible
ggr_selenoid_host_override: selenoid

View File

@@ -0,0 +1,4 @@
---
ansible_host: 10.10.2.127
ansible_user: ansible
ggr_selenoid_host_override: selenoid

View File

@@ -0,0 +1,9 @@
[selenoid_nodes]
localhost
vm1
vm2
[ggr_gateway]
localhost
vm1
vm2

View File

@@ -0,0 +1,20 @@
---
- name: Provision Selenoid Nodes
hosts: selenoid_nodes
become: true
tags:
- provision
roles:
- common
- docker
- selenoid
- name: Provision GGR Gateway
hosts: ggr_gateway
become: true
tags:
- provision
roles:
- common
- docker
- ggr_gateway

View File

@@ -0,0 +1,4 @@
---
- import_playbook: provision.yml
- import_playbook: verify.yml
- import_playbook: tests.yml

View File

@@ -0,0 +1,11 @@
---
- name: Run tests on provisioned host
hosts: ggr_gateway
become: true
tags:
- tests
tasks:
- name: Execute test runner role when enabled
ansible.builtin.include_role:
name: test_runner
when: run_tests_via_ansible | bool

View File

@@ -0,0 +1,8 @@
---
- name: Verify deployed grid endpoints
hosts: ggr_gateway
become: true
tags:
- verify
roles:
- verify

View File

@@ -0,0 +1,19 @@
---
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
- name: Upgrade apt packages (optional)
ansible.builtin.apt:
upgrade: dist
when: run_apt_upgrade | bool
- name: Install base packages
ansible.builtin.apt:
name:
- ca-certificates
- curl
- gnupg
- lsb-release
state: present

View File

@@ -0,0 +1,32 @@
---
- name: Check whether docker CLI is already available
ansible.builtin.command: docker --version
register: docker_cli_check
changed_when: false
failed_when: false
- name: Install Docker packages when docker is missing
ansible.builtin.apt:
name:
- docker.io
- docker-compose-v2
state: present
when: docker_cli_check.rc != 0
- name: Enable Docker service when installed by role and systemd is available
ansible.builtin.service:
name: docker
state: started
enabled: true
when:
- docker_cli_check.rc != 0
- ansible_service_mgr == "systemd"
- name: Add current ansible user to docker group
ansible.builtin.user:
name: "{{ ansible_user }}"
groups: docker
append: true
when:
- ansible_user is defined
- docker_cli_check.rc != 0

View File

@@ -0,0 +1,37 @@
- name: Create GGR directory structure
ansible.builtin.file:
path: "{{ ggr_dir }}/{{ item }}"
state: directory
mode: "0755"
loop:
- quota
- nginx
- name: Render GGR quota file
ansible.builtin.template:
src: quota.xml.j2
dest: "{{ ggr_dir }}/quota/{{ ggr_quota_name }}.xml"
mode: "0644"
- name: Render nginx config
ansible.builtin.template:
src: nginx.conf.j2
dest: "{{ ggr_dir }}/nginx/default.conf"
mode: "0644"
- name: Render docker compose for GGR gateway
ansible.builtin.template:
src: docker-compose.ggr.yml.j2
dest: "{{ ggr_dir }}/{{ ggr_compose_file }}"
mode: "0644"
- name: Stop previous GGR gateway stack and remove orphans
ansible.builtin.command:
cmd: docker compose -f {{ ggr_compose_file }} down --remove-orphans
chdir: "{{ ggr_dir }}"
changed_when: false
- name: Start GGR gateway stack
ansible.builtin.command:
cmd: docker compose -f {{ ggr_compose_file }} up -d --force-recreate
chdir: "{{ ggr_dir }}"

View File

@@ -0,0 +1,46 @@
services:
ggr:
image: "{{ ggr_image }}"
container_name: ggr
restart: unless-stopped
ports:
- "{{ ggr_gateway_port }}:4444"
volumes:
- "./quota:/etc/grid-router/quota:ro"
command:
- "-listen"
- ":4444"
- "-quotaDir"
- "/etc/grid-router/quota"
- "-guests-allowed"
ggr-ui:
image: "{{ ggr_ui_image }}"
container_name: ggr-ui
restart: unless-stopped
ports:
- "{{ ggr_ui_public_port }}:8888"
volumes:
- "./quota:/etc/grid-router/quota:ro"
command:
- "-listen"
- ":8888"
- "-quota-dir"
- "/etc/grid-router/quota"
nginx:
image: "{{ nginx_image }}"
container_name: grid-nginx
restart: unless-stopped
depends_on:
- ggr
- ggr-ui
ports:
- "{{ ggr_nginx_public_port }}:80"
volumes:
- "./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro"
networks:
default:
name: "{{ selenoid_container_network }}"
external: true

View File

@@ -0,0 +1,81 @@
server {
listen 80;
server_name _;
location /ws/ {
proxy_pass http://selenoid:4444/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /vnc/ {
proxy_pass http://selenoid:4444/vnc/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /wd/hub/session {
proxy_pass http://ggr:4444/wd/hub/session;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /wd/hub/ {
proxy_pass http://ggr:4444/wd/hub/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /wd/hub/session/ {
proxy_pass http://selenoid:4444/wd/hub/session/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /session/ {
rewrite ^/session/(.*)$ /wd/hub/session/$1 break;
proxy_pass http://selenoid:4444;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ggr/ {
proxy_pass http://ggr-ui:8888/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://selenoid-ui:8080/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<qa:browsers xmlns:qa="urn:config.gridrouter.qatools.ru">
<browser name="chrome" defaultVersion="{{ selenoid_chrome_versions | first }}">
{% for version in selenoid_chrome_versions %}
<version number="{{ version }}">
<region name="default">
<host name="{{ ggr_selenoid_host_override | default(ansible_host | default(inventory_hostname)) }}" port="{{ selenoid_host_port }}" count="{{ selenoid_limit }}"/>
</region>
</version>
{% endfor %}
</browser>
<browser name="firefox" defaultVersion="{{ selenoid_firefox_versions | first }}">
{% for version in selenoid_firefox_versions %}
<version number="{{ version }}">
<region name="default">
<host name="{{ ggr_selenoid_host_override | default(ansible_host | default(inventory_hostname)) }}" port="{{ selenoid_host_port }}" count="{{ selenoid_limit }}"/>
</region>
</version>
{% endfor %}
</browser>
</qa:browsers>

View File

@@ -0,0 +1,68 @@
---
- name: Create Selenoid directory
ansible.builtin.file:
path: "{{ selenoid_dir }}"
state: directory
mode: "0755"
- name: Create logs directory
ansible.builtin.file:
path: "{{ selenoid_dir }}/{{ item }}"
state: directory
mode: "0777"
loop:
- logs
- name: Render browsers.json
ansible.builtin.template:
src: browsers.json.j2
dest: "{{ selenoid_dir }}/browsers.json"
mode: "0644"
- name: Render docker compose for Selenoid
ansible.builtin.template:
src: docker-compose.selenoid.yml.j2
dest: "{{ selenoid_dir }}/{{ selenoid_compose_file }}"
mode: "0644"
- name: Ensure default chrome version is in the configured versions list
ansible.builtin.assert:
that:
- (selenoid_chrome_versions | length) >= 2
- (selenoid_firefox_versions | length) >= 2
fail_msg: "Invalid Selenoid config: provide at least 2 chrome and 2 firefox versions"
- name: Check shared Docker network for grid stack
ansible.builtin.command:
cmd: docker network inspect {{ selenoid_container_network }}
register: shared_grid_network
changed_when: false
failed_when: false
- name: Create shared Docker network for grid stack
ansible.builtin.command:
cmd: docker network create {{ selenoid_container_network }}
when: shared_grid_network.rc != 0
- name: Stop previous Selenoid stack and remove orphans
ansible.builtin.command:
cmd: docker compose -f {{ selenoid_compose_file }} down --remove-orphans
chdir: "{{ selenoid_dir }}"
changed_when: false
- name: Pull required Chrome images for Selenoid
ansible.builtin.command:
cmd: "docker pull selenoid/vnc:chrome_{{ item }}"
loop: "{{ selenoid_chrome_versions }}"
changed_when: false
- name: Pull required Firefox images for Selenoid
ansible.builtin.command:
cmd: "docker pull selenoid/vnc:firefox_{{ item }}"
loop: "{{ selenoid_firefox_versions }}"
changed_when: false
- name: Start Selenoid stack
ansible.builtin.command:
cmd: docker compose -f {{ selenoid_compose_file }} up -d
chdir: "{{ selenoid_dir }}"

View File

@@ -0,0 +1,26 @@
{
"chrome": {
"default": "{{ selenoid_chrome_versions | first }}",
"versions": {
{% for version in selenoid_chrome_versions %}
"{{ version }}": {
"image": "selenoid/vnc:chrome_{{ version }}",
"port": "4444",
"path": "/"
}{% if not loop.last %},{% endif %}
{% endfor %}
}
},
"firefox": {
"default": "{{ selenoid_firefox_versions | first }}",
"versions": {
{% for version in selenoid_firefox_versions %}
"{{ version }}": {
"image": "selenoid/vnc:firefox_{{ version }}",
"port": "4444",
"path": "/wd/hub"
}{% if not loop.last %},{% endif %}
{% endfor %}
}
}
}

View File

@@ -0,0 +1,46 @@
services:
selenoid:
image: "{{ selenoid_image }}"
container_name: selenoid
restart: unless-stopped
environment:
- DOCKER_API_VERSION=1.45
ports:
- "{{ selenoid_host_port }}:4444"
volumes:
- "./browsers.json:/etc/selenoid/browsers.json:ro"
- "/var/run/docker.sock:/var/run/docker.sock"
- "./logs:/opt/selenoid/logs"
command:
- "-limit"
- "{{ selenoid_limit }}"
- "-conf"
- "/etc/selenoid/browsers.json"
- "-log-output-dir"
- "/opt/selenoid/logs"
- "-container-network"
- "{{ selenoid_container_network }}"
- "-timeout"
- "3m"
- "-session-attempt-timeout"
- "2m"
- "-service-startup-timeout"
- "2m"
selenoid-ui:
image: "{{ selenoid_ui_image }}"
container_name: selenoid-ui
restart: unless-stopped
depends_on:
- selenoid
ports:
- "{{ selenoid_ui_port }}:8080"
command:
- "--selenoid-uri"
- "http://selenoid:4444"
networks:
default:
name: "{{ selenoid_container_network }}"
external: true

View File

@@ -0,0 +1,38 @@
---
- name: Install Java and Maven for test execution
ansible.builtin.apt:
name:
- openjdk-21-jdk
- maven
state: present
update_cache: true
when: test_runner_execute_mode == "target"
- name: Run UI smoke test via Selenoid endpoint
ansible.builtin.command:
cmd: >
mvn
"-Dexecution.mode=selenoid"
"-Dbrowser={{ test_runner_browser }}"
"-Dbrowser.version={{ selenoid_chrome_versions[0] }}"
"-Dselenoid.url=http://{{ (ansible_host | default('127.0.0.1')) if test_runner_execute_mode == 'control' else '127.0.0.1' }}/wd/hub"
"-Dtest={{ test_runner_ui_test_class }}"
test
chdir: "{{ test_runner_control_project_dir if test_runner_execute_mode == 'control' else test_runner_project_dir }}"
environment:
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
delegate_to: "{{ 'localhost' if test_runner_execute_mode == 'control' else omit }}"
become: false
register: ui_test_result
changed_when: false
- name: Run Citrus API tests
ansible.builtin.command:
cmd: mvn -f citrus-tests/pom.xml test
chdir: "{{ test_runner_control_project_dir if test_runner_execute_mode == 'control' else test_runner_project_dir }}"
environment:
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
delegate_to: "{{ 'localhost' if test_runner_execute_mode == 'control' else omit }}"
become: false
register: citrus_test_result
changed_when: false

View File

@@ -0,0 +1,40 @@
---
- name: Wait for Selenoid status endpoint
ansible.builtin.uri:
url: "http://127.0.0.1:{{ selenoid_host_port }}/status"
method: GET
status_code: 200
register: selenoid_status_response
retries: 20
delay: 3
until: selenoid_status_response.status == 200
- name: Wait for GGR ping endpoint
ansible.builtin.uri:
url: "http://127.0.0.1:{{ ggr_gateway_port }}/ping"
method: GET
status_code: 200
register: ggr_ping_response
retries: 20
delay: 3
until: ggr_ping_response.status == 200
- name: Wait for gateway UI endpoint
ansible.builtin.uri:
url: "http://127.0.0.1:{{ ggr_nginx_public_port }}/"
method: GET
status_code: 200
register: ggr_ui_response
retries: 20
delay: 3
until: ggr_ui_response.status == 200
- name: Wait for gateway webdriver status endpoint
ansible.builtin.uri:
url: "http://127.0.0.1:{{ ggr_nginx_public_port }}/wd/hub/status"
method: GET
status_code: 200
register: wd_status_response
retries: 20
delay: 3
until: wd_status_response.status == 200

75
citrus-tests/pom.xml Normal file
View File

@@ -0,0 +1,75 @@
<?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>citrus-tests</artifactId>
<version>1.0-SNAPSHOT</version>
<name>citrus-tests</name>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<citrus.version>3.2.1</citrus.version>
<surefire.version>2.22.2</surefire.version>
<log4j2.version>2.25.1</log4j2.version>
<petstore.base.url>https://petstore.swagger.io</petstore.base.url>
<petstore.base.path>/v2</petstore.base.path>
</properties>
<dependencies>
<dependency>
<groupId>com.consol.citrus</groupId>
<artifactId>citrus-core</artifactId>
<version>${citrus.version}</version>
</dependency>
<dependency>
<groupId>com.consol.citrus</groupId>
<artifactId>citrus-http</artifactId>
<version>${citrus.version}</version>
</dependency>
<dependency>
<groupId>com.consol.citrus</groupId>
<artifactId>citrus-testng</artifactId>
<version>${citrus.version}</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j-impl</artifactId>
<version>${log4j2.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.15.0</version>
<configuration>
<release>${maven.compiler.release}</release>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>${surefire.version}</version>
<configuration>
<systemPropertyVariables>
<petstore.base.url>${petstore.base.url}</petstore.base.url>
<petstore.base.path>${petstore.base.path}</petstore.base.path>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,57 @@
package ru.kovbasa.citrus;
import com.consol.citrus.annotations.CitrusTest;
import com.consol.citrus.message.MessageType;
import com.consol.citrus.testng.spring.TestNGCitrusSpringSupport;
import org.springframework.http.HttpStatus;
import org.testng.annotations.Test;
import java.util.concurrent.ThreadLocalRandom;
import static com.consol.citrus.http.actions.HttpActionBuilder.http;
import static com.consol.citrus.validation.json.JsonPathMessageValidationContext.Builder.jsonPath;
public class PetstoreNegativeScenariosTest extends TestNGCitrusSpringSupport {
@CitrusTest
@Test(description = "GET /pet/{id} returns 404 for unknown id")
public void getMissingPetReturnsNotFound() {
long petId = ThreadLocalRandom.current().nextLong(1_000_000L, Integer.MAX_VALUE);
variable("petId", String.valueOf(petId));
run(http()
.client("petstoreClient")
.send()
.get("/pet/${petId}"));
run(http()
.client("petstoreClient")
.receive()
.response(HttpStatus.NOT_FOUND)
.message()
.validate(jsonPath().expression("$.message", "Pet not found")));
}
@CitrusTest
@Test(description = "POST /pet with malformed JSON returns 400")
public void createPetWithMalformedJsonReturnsError() {
run(http()
.client("petstoreClient")
.send()
.post("/pet")
.message()
.type(MessageType.JSON)
.header("Content-Type", "application/json")
.body("""
{
"id": 123456,
"name": "Broken pet",
"photoUrls": ["https://petstore.test/photo.jpg"]
"""));
run(http()
.client("petstoreClient")
.receive()
.response(HttpStatus.BAD_REQUEST));
}
}

View File

@@ -0,0 +1,81 @@
package ru.kovbasa.citrus;
import com.consol.citrus.annotations.CitrusTest;
import com.consol.citrus.message.MessageType;
import com.consol.citrus.testng.spring.TestNGCitrusSpringSupport;
import org.springframework.http.HttpStatus;
import org.testng.annotations.Test;
import java.util.concurrent.ThreadLocalRandom;
import static com.consol.citrus.http.actions.HttpActionBuilder.http;
import static com.consol.citrus.validation.json.JsonPathMessageValidationContext.Builder.jsonPath;
public class PetstorePositiveScenariosTest extends TestNGCitrusSpringSupport {
@CitrusTest
@Test(description = "POST /pet creates resource and GET /pet/{id} returns created pet")
public void createPetAndGetById() {
long petId = ThreadLocalRandom.current().nextLong(1_000_000L, Integer.MAX_VALUE);
String petName = "Rex-" + petId;
variable("petId", String.valueOf(petId));
variable("petName", petName);
run(http()
.client("petstoreClient")
.send()
.post("/pet")
.message()
.type(MessageType.JSON)
.header("Content-Type", "application/json")
.body("""
{
"id": ${petId},
"category": {
"id": 1,
"name": "Dogs"
},
"name": "${petName}",
"photoUrls": ["https://petstore.test/photo.jpg"],
"tags": [{
"id": 1,
"name": "api-test"
}],
"status": "available"
}
"""));
run(http()
.client("petstoreClient")
.receive()
.response(HttpStatus.OK)
.message()
.validate(jsonPath().expression("$.id", "${petId}"))
.validate(jsonPath().expression("$.name", "${petName}"))
.validate(jsonPath().expression("$.status", "available")));
run(http()
.client("petstoreClient")
.send()
.get("/pet/${petId}"));
run(http()
.client("petstoreClient")
.receive()
.response(HttpStatus.OK)
.message()
.validate(jsonPath().expression("$.id", "${petId}"))
.validate(jsonPath().expression("$.name", "${petName}")));
run(http()
.client("petstoreClient")
.send()
.delete("/pet/${petId}"));
run(http()
.client("petstoreClient")
.receive()
.response(HttpStatus.OK));
}
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:citrus="http://www.citrusframework.org/schema/config"
xmlns:citrus-http="http://www.citrusframework.org/schema/http/config"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="
http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
http://www.citrusframework.org/schema/config http://www.citrusframework.org/schema/config/citrus-config.xsd
http://www.citrusframework.org/schema/http/config http://www.citrusframework.org/schema/http/config/citrus-http-config.xsd">
<context:property-placeholder location="classpath:citrus.properties"/>
<citrus:global-variables>
<citrus:file path="classpath:citrus.properties"/>
</citrus:global-variables>
<citrus-http:client id="petstoreClient"
request-url="${petstore.base.url}${petstore.base.path}"
timeout="10000"/>
</beans>

View File

@@ -0,0 +1,5 @@
default.test.author=spawn
default.test.package=ru.kovbasa.citrus
petstore.base.url=https://petstore.swagger.io
petstore.base.path=/v2

View File

@@ -0,0 +1,11 @@
status = WARN
name = citrus-log4j2
appender.console.type = Console
appender.console.name = Console
appender.console.layout.type = PatternLayout
appender.console.layout.pattern = %d{HH:mm:ss.SSS} %-5p %c{1} - %m%n
rootLogger.level = INFO
rootLogger.appenderRefs = stdout
rootLogger.appenderRef.stdout.ref = Console

30
pom.xml
View File

@@ -16,22 +16,26 @@
<!-- Конфиг тестов -->
<base.url>https://otus.ru</base.url>
<course.name>Python Developer</course.name>
<execution.mode>local</execution.mode>
<browser>chrome</browser>
<browser.version></browser.version>
<selenoid.url>http://localhost/wd/hub</selenoid.url>
<!-- Dependencies -->
<selenium.version>4.38.0</selenium.version>
<junit.version>5.10.0</junit.version>
<selenium.version>4.41.0</selenium.version>
<junit.version>6.0.2</junit.version>
<webdrivermanager.version>6.3.3</webdrivermanager.version>
<guice.version>5.1.0</guice.version>
<jsoup.version>1.21.2</jsoup.version>
<slf4j.version>2.0.11</slf4j.version>
<logback.version>1.4.14</logback.version>
<guava.version>32.1.3-jre</guava.version>
<guice.version>7.0.0</guice.version>
<jsoup.version>1.22.1</jsoup.version>
<slf4j.version>2.0.17</slf4j.version>
<logback.version>1.5.32</logback.version>
<guava.version>33.5.0-jre</guava.version>
<!-- Plugins -->
<maven.compiler.version>3.11.0</maven.compiler.version>
<surefire.version>3.1.2</surefire.version>
<maven.compiler.version>3.15.0</maven.compiler.version>
<surefire.version>3.5.4</surefire.version>
<checkstyle.plugin.version>3.6.0</checkstyle.plugin.version>
<spotbugs.plugin.version>4.9.8.0</spotbugs.plugin.version>
<spotbugs.plugin.version>4.9.8.2</spotbugs.plugin.version>
<spotbugs.version>4.9.8</spotbugs.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -52,7 +56,7 @@
<version>${webdrivermanager.version}</version>
</dependency>
<!-- JUnit 5 -->
<!-- JUnit -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
@@ -118,6 +122,10 @@
<systemPropertyVariables>
<base.url>${base.url}</base.url>
<course.name>${course.name}</course.name>
<execution.mode>${execution.mode}</execution.mode>
<browser>${browser}</browser>
<browser.version>${browser.version}</browser.version>
<selenoid.url>${selenoid.url}</selenoid.url>
</systemPropertyVariables>
</configuration>
</plugin>

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 -> {
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 {
return new CourseItem(card.title(), card.startDate());
} catch (RuntimeException e) {
return null;
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);
new Actions(driver)
.moveToElement(card)
.perform();
new Actions(driver).moveToElement(card).perform();
card.click();
return new CoursePage(driver);
}
} catch (StaleElementReferenceException ignored) {
// Re-query cards when DOM updates during search/click.
}
}
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 -> {
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);
}
}
}