diff --git a/README.md b/README.md index 5c8254e..2438a21 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,149 @@ -# OTUS Selenium Homework 1 +# OTUS Homework 4: Selenoid + GGR + Nginx -## Цель проекта -Автоматизировать 3 UI-сценария на `https://otus.ru` с использованием Selenium WebDriver 4+, JUnit 6, Guice DI, listeners, Stream API, Jsoup и обязательных проверок качества (Checkstyle + SpotBugs). +В этом проекте я собрал инфраструктуру для запуска UI-тестов через Selenoid и добавил Ansible-деплой для локального стенда и удаленных VM. -## Стек технологий -- Java 21 -- Maven -- Selenium `4.40.0` -- WebDriverManager `6.3.3` -- JUnit `6.0.2` -- Guice `7.0.0` -- Jsoup `1.22.1` -- Guava `33.5.0-jre` -- SLF4J `2.0.17` -- Logback `1.5.31` -- 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:///` +- WebDriver: `http:///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 6 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: +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 +``` diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000..403f2a3 --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,5 @@ +[defaults] +inventory = inventory/hosts.ini +host_key_checking = False +retry_files_enabled = False +interpreter_python = auto_silent diff --git a/ansible/inventory/group_vars/all.yml b/ansible/inventory/group_vars/all.yml new file mode 100644 index 0000000..6391a5b --- /dev/null +++ b/ansible/inventory/group_vars/all.yml @@ -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 diff --git a/ansible/inventory/host_vars/localhost.yml b/ansible/inventory/host_vars/localhost.yml new file mode 100644 index 0000000..740026b --- /dev/null +++ b/ansible/inventory/host_vars/localhost.yml @@ -0,0 +1,3 @@ +--- +ansible_connection: local +ggr_selenoid_host_override: selenoid diff --git a/ansible/inventory/host_vars/vm1.yml b/ansible/inventory/host_vars/vm1.yml new file mode 100644 index 0000000..9d723a7 --- /dev/null +++ b/ansible/inventory/host_vars/vm1.yml @@ -0,0 +1,4 @@ +--- +ansible_host: 10.10.2.112 +ansible_user: ansible +ggr_selenoid_host_override: selenoid diff --git a/ansible/inventory/host_vars/vm2.yml b/ansible/inventory/host_vars/vm2.yml new file mode 100644 index 0000000..bb996d0 --- /dev/null +++ b/ansible/inventory/host_vars/vm2.yml @@ -0,0 +1,4 @@ +--- +ansible_host: 10.10.2.127 +ansible_user: ansible +ggr_selenoid_host_override: selenoid diff --git a/ansible/inventory/hosts.ini b/ansible/inventory/hosts.ini new file mode 100644 index 0000000..8c5280a --- /dev/null +++ b/ansible/inventory/hosts.ini @@ -0,0 +1,9 @@ +[selenoid_nodes] +localhost +vm1 +vm2 + +[ggr_gateway] +localhost +vm1 +vm2 diff --git a/ansible/playbooks/provision.yml b/ansible/playbooks/provision.yml new file mode 100644 index 0000000..03739d8 --- /dev/null +++ b/ansible/playbooks/provision.yml @@ -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 diff --git a/ansible/playbooks/site.yml b/ansible/playbooks/site.yml new file mode 100644 index 0000000..154b1e3 --- /dev/null +++ b/ansible/playbooks/site.yml @@ -0,0 +1,4 @@ +--- +- import_playbook: provision.yml +- import_playbook: verify.yml +- import_playbook: tests.yml diff --git a/ansible/playbooks/tests.yml b/ansible/playbooks/tests.yml new file mode 100644 index 0000000..5cfe6f3 --- /dev/null +++ b/ansible/playbooks/tests.yml @@ -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 diff --git a/ansible/playbooks/verify.yml b/ansible/playbooks/verify.yml new file mode 100644 index 0000000..a13ea2b --- /dev/null +++ b/ansible/playbooks/verify.yml @@ -0,0 +1,8 @@ +--- +- name: Verify deployed grid endpoints + hosts: ggr_gateway + become: true + tags: + - verify + roles: + - verify diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml new file mode 100644 index 0000000..8e6f601 --- /dev/null +++ b/ansible/roles/common/tasks/main.yml @@ -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 diff --git a/ansible/roles/docker/tasks/main.yml b/ansible/roles/docker/tasks/main.yml new file mode 100644 index 0000000..d724006 --- /dev/null +++ b/ansible/roles/docker/tasks/main.yml @@ -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 diff --git a/ansible/roles/ggr_gateway/tasks/main.yml b/ansible/roles/ggr_gateway/tasks/main.yml new file mode 100644 index 0000000..cca639c --- /dev/null +++ b/ansible/roles/ggr_gateway/tasks/main.yml @@ -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 }}" diff --git a/ansible/roles/ggr_gateway/templates/docker-compose.ggr.yml.j2 b/ansible/roles/ggr_gateway/templates/docker-compose.ggr.yml.j2 new file mode 100644 index 0000000..570ad04 --- /dev/null +++ b/ansible/roles/ggr_gateway/templates/docker-compose.ggr.yml.j2 @@ -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 diff --git a/ansible/roles/ggr_gateway/templates/nginx.conf.j2 b/ansible/roles/ggr_gateway/templates/nginx.conf.j2 new file mode 100644 index 0000000..b8b6290 --- /dev/null +++ b/ansible/roles/ggr_gateway/templates/nginx.conf.j2 @@ -0,0 +1,53 @@ +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/ { + 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 /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; + } +} diff --git a/ansible/roles/ggr_gateway/templates/quota.xml.j2 b/ansible/roles/ggr_gateway/templates/quota.xml.j2 new file mode 100644 index 0000000..fbd8c66 --- /dev/null +++ b/ansible/roles/ggr_gateway/templates/quota.xml.j2 @@ -0,0 +1,21 @@ + + + +{% for version in selenoid_chrome_versions %} + + + + + +{% endfor %} + + +{% for version in selenoid_firefox_versions %} + + + + + +{% endfor %} + + diff --git a/ansible/roles/selenoid/tasks/main.yml b/ansible/roles/selenoid/tasks/main.yml new file mode 100644 index 0000000..a653e54 --- /dev/null +++ b/ansible/roles/selenoid/tasks/main.yml @@ -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 }}" diff --git a/ansible/roles/selenoid/templates/browsers.json.j2 b/ansible/roles/selenoid/templates/browsers.json.j2 new file mode 100644 index 0000000..f530372 --- /dev/null +++ b/ansible/roles/selenoid/templates/browsers.json.j2 @@ -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 %} + } + } +} diff --git a/ansible/roles/selenoid/templates/docker-compose.selenoid.yml.j2 b/ansible/roles/selenoid/templates/docker-compose.selenoid.yml.j2 new file mode 100644 index 0000000..d95a87c --- /dev/null +++ b/ansible/roles/selenoid/templates/docker-compose.selenoid.yml.j2 @@ -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 + diff --git a/ansible/roles/test_runner/tasks/main.yml b/ansible/roles/test_runner/tasks/main.yml new file mode 100644 index 0000000..62147e2 --- /dev/null +++ b/ansible/roles/test_runner/tasks/main.yml @@ -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 diff --git a/ansible/roles/verify/tasks/main.yml b/ansible/roles/verify/tasks/main.yml new file mode 100644 index 0000000..9190f18 --- /dev/null +++ b/ansible/roles/verify/tasks/main.yml @@ -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 diff --git a/citrus-tests/pom.xml b/citrus-tests/pom.xml new file mode 100644 index 0000000..59271cc --- /dev/null +++ b/citrus-tests/pom.xml @@ -0,0 +1,75 @@ + + + + 4.0.0 + + ru.kovbasa + citrus-tests + 1.0-SNAPSHOT + citrus-tests + + + 21 + UTF-8 + + 3.2.1 + 2.22.2 + 2.25.1 + + https://petstore.swagger.io + /v2 + + + + + com.consol.citrus + citrus-core + ${citrus.version} + + + com.consol.citrus + citrus-http + ${citrus.version} + + + com.consol.citrus + citrus-testng + ${citrus.version} + + + + org.apache.logging.log4j + log4j-slf4j-impl + ${log4j2.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.15.0 + + ${maven.compiler.release} + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${surefire.version} + + + ${petstore.base.url} + ${petstore.base.path} + + + + + + + diff --git a/citrus-tests/src/test/java/ru/kovbasa/citrus/PetstoreNegativeScenariosTest.java b/citrus-tests/src/test/java/ru/kovbasa/citrus/PetstoreNegativeScenariosTest.java new file mode 100644 index 0000000..475663e --- /dev/null +++ b/citrus-tests/src/test/java/ru/kovbasa/citrus/PetstoreNegativeScenariosTest.java @@ -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)); + } +} diff --git a/citrus-tests/src/test/java/ru/kovbasa/citrus/PetstorePositiveScenariosTest.java b/citrus-tests/src/test/java/ru/kovbasa/citrus/PetstorePositiveScenariosTest.java new file mode 100644 index 0000000..4a73a60 --- /dev/null +++ b/citrus-tests/src/test/java/ru/kovbasa/citrus/PetstorePositiveScenariosTest.java @@ -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)); + } +} diff --git a/citrus-tests/src/test/resources/citrus-context.xml b/citrus-tests/src/test/resources/citrus-context.xml new file mode 100644 index 0000000..0bedf5a --- /dev/null +++ b/citrus-tests/src/test/resources/citrus-context.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + diff --git a/citrus-tests/src/test/resources/citrus.properties b/citrus-tests/src/test/resources/citrus.properties new file mode 100644 index 0000000..f466935 --- /dev/null +++ b/citrus-tests/src/test/resources/citrus.properties @@ -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 diff --git a/citrus-tests/src/test/resources/log4j2.properties b/citrus-tests/src/test/resources/log4j2.properties new file mode 100644 index 0000000..d534deb --- /dev/null +++ b/citrus-tests/src/test/resources/log4j2.properties @@ -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 diff --git a/pom.xml b/pom.xml index 3b1210f..07daa32 100644 --- a/pom.xml +++ b/pom.xml @@ -16,15 +16,19 @@ https://otus.ru Python Developer + local + chrome + + http://localhost/wd/hub - 4.40.0 + 4.41.0 6.0.2 6.3.3 7.0.0 1.22.1 2.0.17 - 1.5.31 + 1.5.32 33.5.0-jre @@ -52,7 +56,7 @@ ${webdrivermanager.version} - + org.junit.jupiter junit-jupiter @@ -118,6 +122,10 @@ ${base.url} ${course.name} + ${execution.mode} + ${browser} + ${browser.version} + ${selenoid.url} diff --git a/src/main/java/ru/kovbasa/config/DriverModule.java b/src/main/java/ru/kovbasa/config/DriverModule.java index 5248999..c34b4a4 100644 --- a/src/main/java/ru/kovbasa/config/DriverModule.java +++ b/src/main/java/ru/kovbasa/config/DriverModule.java @@ -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(); + } } diff --git a/src/main/java/ru/kovbasa/config/TestConfig.java b/src/main/java/ru/kovbasa/config/TestConfig.java index fa7527c..fb2e42d 100644 --- a/src/main/java/ru/kovbasa/config/TestConfig.java +++ b/src/main/java/ru/kovbasa/config/TestConfig.java @@ -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); + } } diff --git a/src/main/java/ru/kovbasa/driver/MobileChromeDriverFactory.java b/src/main/java/ru/kovbasa/driver/MobileChromeDriverFactory.java new file mode 100644 index 0000000..1e5f7f1 --- /dev/null +++ b/src/main/java/ru/kovbasa/driver/MobileChromeDriverFactory.java @@ -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); + } +} diff --git a/src/main/java/ru/kovbasa/driver/RemoteDriverFactory.java b/src/main/java/ru/kovbasa/driver/RemoteDriverFactory.java new file mode 100644 index 0000000..d1dd20d --- /dev/null +++ b/src/main/java/ru/kovbasa/driver/RemoteDriverFactory.java @@ -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); + } + } +} diff --git a/src/main/java/ru/kovbasa/driver/WebDriverProvider.java b/src/main/java/ru/kovbasa/driver/WebDriverProvider.java index 2b40ec1..5718fb3 100644 --- a/src/main/java/ru/kovbasa/driver/WebDriverProvider.java +++ b/src/main/java/ru/kovbasa/driver/WebDriverProvider.java @@ -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); diff --git a/src/main/java/ru/kovbasa/pages/CatalogPage.java b/src/main/java/ru/kovbasa/pages/CatalogPage.java index 889ce58..9a5de48 100644 --- a/src/main/java/ru/kovbasa/pages/CatalogPage.java +++ b/src/main/java/ru/kovbasa/pages/CatalogPage.java @@ -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) drv -> !drv.findElements(courseCards).isEmpty()); return this; } @@ -67,22 +70,38 @@ public class CatalogPage { } public List getAllCourseCards() { + final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(20)); + wait.until((ExpectedCondition) drv -> !drv.findElements(courseCards).isEmpty()); return driver.findElements(courseCards).stream() .map(CourseCard::new) .toList(); } public List 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) drv -> !drv.findElements(courseCards).isEmpty()); + + for (int attempt = 1; attempt <= 3; attempt++) { + try { + final List 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 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) 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; + } } } diff --git a/src/main/java/ru/kovbasa/pages/MainPage.java b/src/main/java/ru/kovbasa/pages/MainPage.java index 2499cab..7751e35 100644 --- a/src/main/java/ru/kovbasa/pages/MainPage.java +++ b/src/main/java/ru/kovbasa/pages/MainPage.java @@ -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 els = wait.until(drv -> { - final List found = drv.findElements(categories).stream() - .filter(WebElement::isDisplayed) - .toList(); - return found.isEmpty() ? null : found; - }); + List els = findVisibleCategories(wait); + if (els.isEmpty()) { + clickLearningMenu(wait); + els = wait.until(drv -> { + final List 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 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); + } + } }