Compare commits
3 Commits
homework_4
...
a349e6cdee
| Author | SHA1 | Date | |
|---|---|---|---|
| a349e6cdee | |||
| d411689467 | |||
| b414beb6fb |
216
README.md
216
README.md
@@ -1,149 +1,111 @@
|
|||||||
# OTUS Homework 4: Selenoid + GGR + Nginx
|
# OTUS Selenium Homework 2 (BDD + Cucumber)
|
||||||
|
|
||||||
В этом проекте я собрал инфраструктуру для запуска UI-тестов через Selenoid и добавил Ansible-деплой для локального стенда и удаленных VM.
|
## Цель проекта
|
||||||
|
Реализовать BDD-подход на `https://otus.ru` с использованием Selenium WebDriver 4+, Cucumber (русские feature), JUnit 5, Guice DI, listeners, Stream API, Jsoup и проверок качества (Checkstyle + SpotBugs).
|
||||||
|
|
||||||
## Что реализовано
|
## Стек технологий
|
||||||
- UI-тесты из HW1 запускаются через удаленный `RemoteWebDriver` (Selenoid).
|
- Java 21
|
||||||
- Поддержаны браузеры: `chrome`, `firefox`, `mobile_chrome`.
|
- Maven
|
||||||
- Подняты сервисы: `selenoid`, `selenoid-ui`, `ggr`, `nginx`.
|
- Selenium `4.40.0`
|
||||||
- Один inventory для всех стендов: `localhost` (WSL), `vm1`, `vm2`.
|
- JUnit `5.14.2` + JUnit Platform Suite `1.14.2`
|
||||||
|
- Cucumber (`cucumber-java` `7.34.2`, `cucumber-junit-platform-engine` `7.34.2`)
|
||||||
|
- Guice `7.0.0`
|
||||||
|
- Jsoup `1.22.1`
|
||||||
|
- Guava `33.5.0-jre`
|
||||||
|
- SLF4J `2.0.17`
|
||||||
|
- Logback `1.5.31`
|
||||||
|
- Checkstyle
|
||||||
|
- SpotBugs
|
||||||
|
|
||||||
Точки доступа:
|
## Реализованные BDD-сценарии
|
||||||
- UI: `http://<host_ip>/`
|
1. Выбор браузера через feature.
|
||||||
- WebDriver: `http://<host_ip>/wd/hub`
|
- Шаг: `Дано Открыт браузер Chrome`
|
||||||
|
- Фабрика драйвера выбирается по `browser` property (`chrome`)
|
||||||
|
|
||||||
## Версии в проекте
|
2. Поиск курса по имени и случайный выбор при множественных результатах.
|
||||||
Настройки лежат в `ansible/inventory/group_vars/all.yml`.
|
- Название курса задается в feature
|
||||||
|
- Выбирается случайный курс из найденных
|
||||||
|
- Проверяется заголовок открытой страницы курса
|
||||||
|
|
||||||
Браузеры:
|
3. Поиск курсов, стартующих в указанную дату или позже.
|
||||||
- Chrome: `128.0`, `127.0`
|
- Дата задается в feature (`dd.MM.yyyy`)
|
||||||
- Firefox: `125.0`, `124.0`
|
- Выполняется фильтрация `startDate >= dateFrom`
|
||||||
|
- В консоль выводится: название + дата старта
|
||||||
|
|
||||||
Образы инфраструктуры:
|
4. Раздел «Обучение» -> «Подготовительные курсы».
|
||||||
- `aerokube/selenoid:1.11.3`
|
- Открывается пункт `Подготовительные курсы` из меню `Обучение`
|
||||||
- `aerokube/selenoid-ui:1.10.11`
|
- При необходимости нажимается `Показать еще ...`, чтобы загрузить весь список
|
||||||
- `aerokube/ggr:1.7.2`
|
- Из списка выбираются самый дорогой и самый дешевый
|
||||||
- `aerokube/ggr-ui:latest-release`
|
- Выбор реализован через Stream API + `filter`
|
||||||
- `nginx:1.28.2`
|
- Информация о курсах выводится в консоль
|
||||||
|
|
||||||
## Что нужно подготовить перед запуском
|
5. Общий каталог курсов: самый дорогой и самый дешевый по полной стоимости со скидкой.
|
||||||
На каждой VM должен быть пользователь `ansible`:
|
- Открывается `https://otus.ru/catalog/courses`
|
||||||
- вход по SSH-ключу;
|
- Для каждого курса берется цена сравнения:
|
||||||
- `sudo` без пароля (`NOPASSWD`).
|
- В приоритете `Полная` -> `Полная стоимость со скидкой`
|
||||||
|
- Для упрощенных online-страниц используется fallback по видимой цене
|
||||||
|
- Через `filter` выбираются max/min, результат выводится в консоль
|
||||||
|
|
||||||
Ниже один простой вариант первичной подготовки VM.
|
## Архитектура
|
||||||
|
- 2-уровневый тест-дизайн: `BDD steps` + `page objects`
|
||||||
|
- DI через Guice для step definitions и страниц
|
||||||
|
- Фабрика драйвера:
|
||||||
|
- `DriverFactory` (интерфейс)
|
||||||
|
- `ChromeDriverFactory` (реализация)
|
||||||
|
- `WebDriverProvider` (жизненный цикл драйвера + декоратор listener)
|
||||||
|
- Подсветка через listener:
|
||||||
|
- Подсветка ставится в `beforeClick`
|
||||||
|
- Снимается в `afterClick`
|
||||||
|
- Стиль элемента возвращается в исходное состояние
|
||||||
|
|
||||||
1. На локальной машине генерирую ключи (если их еще нет):
|
## Структура проекта
|
||||||
|
- `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/resources/features` — `.feature` файлы (русский язык)
|
||||||
|
- `src/test/java/ru/kovbasa/bdd` — Cucumber runner
|
||||||
|
- `src/test/java/ru/kovbasa/bdd/steps` — step definitions
|
||||||
|
- `src/test/java/ru/kovbasa/bdd/hooks` — lifecycle hooks
|
||||||
|
|
||||||
|
## Требования к окружению
|
||||||
|
1. Установлен JDK 21 (доступен в `PATH`)
|
||||||
|
2. Установлен Google Chrome
|
||||||
|
3. Установлен Maven 3.9+
|
||||||
|
4. Есть доступ в интернет и к `otus.ru` (тесты запускаются на живом сайте)
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
### 1. Запуск всех тестов
|
||||||
```bash
|
```bash
|
||||||
ssh-keygen -f ~/.ssh/id_ed25519
|
mvn test
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Захожу на VM под `root`:
|
### 2. Запуск конкретного Cucumber runner
|
||||||
```bash
|
```bash
|
||||||
ssh root@10.10.2.127
|
mvn "-Dtest=ru.kovbasa.bdd.CucumberTest" test
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Под `root` создаю пользователя `ansible`, добавляю в `sudo` и в `sudoers`:
|
### 3. Локальная полная проверка (необязательно для сдачи ДЗ)
|
||||||
```bash
|
```bash
|
||||||
useradd -m -s /bin/bash ansible
|
mvn verify
|
||||||
usermod -aG sudo ansible
|
|
||||||
echo "ansible ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/ansible
|
|
||||||
chmod 440 /etc/sudoers.d/ansible
|
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Под `root` кладу публичный ключ в профиль `ansible`:
|
## Параметры запуска
|
||||||
|
Пробрасываются через Maven Surefire:
|
||||||
|
- `base.url` (по умолчанию `https://otus.ru`)
|
||||||
|
- `course.name` (по умолчанию `Python Developer`)
|
||||||
|
- `browser` (по умолчанию `chrome`)
|
||||||
|
|
||||||
|
Пример переопределения:
|
||||||
```bash
|
```bash
|
||||||
install -d -m 700 -o ansible -g ansible /home/ansible/.ssh
|
mvn "-Dbrowser=chrome" "-Dcourse.name=Python" test
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
6. Проверяю вход под `ansible`:
|
## Quality Gates
|
||||||
```bash
|
- Checkstyle и SpotBugs выполняются в фазе `verify` (опционально)
|
||||||
ssh -i ~/.ssh/id_ed25519 ansible@10.10.2.127 "whoami && sudo -n true && echo sudo_ok"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Как добавить новые VM
|
## Примечания
|
||||||
1. Добавляю хост в `ansible/inventory/hosts.ini`.
|
- Тесты зависят от текущей верстки/контента `otus.ru`.
|
||||||
2. Создаю `ansible/inventory/host_vars/vmN.yml`:
|
- По умолчанию в `mvn test` запускается только `CucumberTest`.
|
||||||
```yaml
|
- Для сдачи ДЗ достаточно успешного запуска `mvn test`.
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
[defaults]
|
|
||||||
inventory = inventory/hosts.ini
|
|
||||||
host_key_checking = False
|
|
||||||
retry_files_enabled = False
|
|
||||||
interpreter_python = auto_silent
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
---
|
|
||||||
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
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
---
|
|
||||||
ansible_connection: local
|
|
||||||
ggr_selenoid_host_override: selenoid
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
---
|
|
||||||
ansible_host: 10.10.2.112
|
|
||||||
ansible_user: ansible
|
|
||||||
ggr_selenoid_host_override: selenoid
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
---
|
|
||||||
ansible_host: 10.10.2.127
|
|
||||||
ansible_user: ansible
|
|
||||||
ggr_selenoid_host_override: selenoid
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
[selenoid_nodes]
|
|
||||||
localhost
|
|
||||||
vm1
|
|
||||||
vm2
|
|
||||||
|
|
||||||
[ggr_gateway]
|
|
||||||
localhost
|
|
||||||
vm1
|
|
||||||
vm2
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
- 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
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
---
|
|
||||||
- import_playbook: provision.yml
|
|
||||||
- import_playbook: verify.yml
|
|
||||||
- import_playbook: tests.yml
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
- 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
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
- name: Verify deployed grid endpoints
|
|
||||||
hosts: ggr_gateway
|
|
||||||
become: true
|
|
||||||
tags:
|
|
||||||
- verify
|
|
||||||
roles:
|
|
||||||
- verify
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
---
|
|
||||||
- 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
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
---
|
|
||||||
- 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
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
- 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 }}"
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
---
|
|
||||||
- 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 }}"
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"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 %}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
- 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
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
---
|
|
||||||
- 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
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
default.test.author=spawn
|
|
||||||
default.test.package=ru.kovbasa.citrus
|
|
||||||
|
|
||||||
petstore.base.url=https://petstore.swagger.io
|
|
||||||
petstore.base.path=/v2
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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
|
|
||||||
64
pom.xml
64
pom.xml
@@ -16,20 +16,18 @@
|
|||||||
<!-- Конфиг тестов -->
|
<!-- Конфиг тестов -->
|
||||||
<base.url>https://otus.ru</base.url>
|
<base.url>https://otus.ru</base.url>
|
||||||
<course.name>Python Developer</course.name>
|
<course.name>Python Developer</course.name>
|
||||||
<execution.mode>local</execution.mode>
|
|
||||||
<browser>chrome</browser>
|
<browser>chrome</browser>
|
||||||
<browser.version></browser.version>
|
|
||||||
<selenoid.url>http://localhost/wd/hub</selenoid.url>
|
|
||||||
|
|
||||||
<!-- Dependencies -->
|
<!-- Dependencies -->
|
||||||
<selenium.version>4.41.0</selenium.version>
|
<selenium.version>4.40.0</selenium.version>
|
||||||
<junit.version>6.0.2</junit.version>
|
<junit.version>5.14.2</junit.version>
|
||||||
<webdrivermanager.version>6.3.3</webdrivermanager.version>
|
<junit.platform.suite.version>1.14.2</junit.platform.suite.version>
|
||||||
|
<cucumber.version>7.34.2</cucumber.version>
|
||||||
<guice.version>7.0.0</guice.version>
|
<guice.version>7.0.0</guice.version>
|
||||||
|
<guava.version>33.5.0-jre</guava.version>
|
||||||
<jsoup.version>1.22.1</jsoup.version>
|
<jsoup.version>1.22.1</jsoup.version>
|
||||||
<slf4j.version>2.0.17</slf4j.version>
|
<slf4j.version>2.0.17</slf4j.version>
|
||||||
<logback.version>1.5.32</logback.version>
|
<logback.version>1.5.31</logback.version>
|
||||||
<guava.version>33.5.0-jre</guava.version>
|
|
||||||
|
|
||||||
<!-- Plugins -->
|
<!-- Plugins -->
|
||||||
<maven.compiler.version>3.15.0</maven.compiler.version>
|
<maven.compiler.version>3.15.0</maven.compiler.version>
|
||||||
@@ -49,13 +47,6 @@
|
|||||||
<version>${selenium.version}</version>
|
<version>${selenium.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- WebDriverManager -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.github.bonigarcia</groupId>
|
|
||||||
<artifactId>webdrivermanager</artifactId>
|
|
||||||
<version>${webdrivermanager.version}</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- JUnit -->
|
<!-- JUnit -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.junit.jupiter</groupId>
|
<groupId>org.junit.jupiter</groupId>
|
||||||
@@ -64,6 +55,27 @@
|
|||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.platform</groupId>
|
||||||
|
<artifactId>junit-platform-suite</artifactId>
|
||||||
|
<version>${junit.platform.suite.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.cucumber</groupId>
|
||||||
|
<artifactId>cucumber-java</artifactId>
|
||||||
|
<version>${cucumber.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.cucumber</groupId>
|
||||||
|
<artifactId>cucumber-junit-platform-engine</artifactId>
|
||||||
|
<version>${cucumber.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Guice -->
|
<!-- Guice -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.google.inject</groupId>
|
<groupId>com.google.inject</groupId>
|
||||||
@@ -71,6 +83,13 @@
|
|||||||
<version>${guice.version}</version>
|
<version>${guice.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Force modern Guava to avoid deprecated Unsafe usage on new JDKs -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.guava</groupId>
|
||||||
|
<artifactId>guava</artifactId>
|
||||||
|
<version>${guava.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Jsoup -->
|
<!-- Jsoup -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.jsoup</groupId>
|
<groupId>org.jsoup</groupId>
|
||||||
@@ -91,12 +110,6 @@
|
|||||||
<version>${logback.version}</version>
|
<version>${logback.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Guava -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.google.guava</groupId>
|
|
||||||
<artifactId>guava</artifactId>
|
|
||||||
<version>${guava.version}</version>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
@@ -119,13 +132,16 @@
|
|||||||
<version>${surefire.version}</version>
|
<version>${surefire.version}</version>
|
||||||
<configuration>
|
<configuration>
|
||||||
<useModulePath>false</useModulePath>
|
<useModulePath>false</useModulePath>
|
||||||
|
<argLine>
|
||||||
|
-Djava.util.logging.config.file=${project.basedir}/src/test/resources/logging.properties
|
||||||
|
</argLine>
|
||||||
|
<includes>
|
||||||
|
<include>**/CucumberTest.java</include>
|
||||||
|
</includes>
|
||||||
<systemPropertyVariables>
|
<systemPropertyVariables>
|
||||||
<base.url>${base.url}</base.url>
|
<base.url>${base.url}</base.url>
|
||||||
<course.name>${course.name}</course.name>
|
<course.name>${course.name}</course.name>
|
||||||
<execution.mode>${execution.mode}</execution.mode>
|
|
||||||
<browser>${browser}</browser>
|
<browser>${browser}</browser>
|
||||||
<browser.version>${browser.version}</browser.version>
|
|
||||||
<selenoid.url>${selenoid.url}</selenoid.url>
|
|
||||||
</systemPropertyVariables>
|
</systemPropertyVariables>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
|||||||
@@ -1,30 +1,16 @@
|
|||||||
package ru.kovbasa.config;
|
package ru.kovbasa.config;
|
||||||
|
|
||||||
import com.google.inject.AbstractModule;
|
import com.google.inject.AbstractModule;
|
||||||
import com.google.inject.Provides;
|
|
||||||
import com.google.inject.Singleton;
|
import com.google.inject.Singleton;
|
||||||
import ru.kovbasa.driver.ChromeDriverFactory;
|
|
||||||
import ru.kovbasa.driver.DriverFactory;
|
import ru.kovbasa.driver.DriverFactory;
|
||||||
import ru.kovbasa.driver.MobileChromeDriverFactory;
|
import ru.kovbasa.driver.SelectableDriverFactory;
|
||||||
import ru.kovbasa.driver.RemoteDriverFactory;
|
|
||||||
import ru.kovbasa.driver.WebDriverProvider;
|
import ru.kovbasa.driver.WebDriverProvider;
|
||||||
|
|
||||||
public class DriverModule extends AbstractModule {
|
public class DriverModule extends AbstractModule {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void configure() {
|
protected void configure() {
|
||||||
|
bind(DriverFactory.class).to(SelectableDriverFactory.class).in(Singleton.class);
|
||||||
bind(WebDriverProvider.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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,61 +2,18 @@ package ru.kovbasa.config;
|
|||||||
|
|
||||||
public final class TestConfig {
|
public final class TestConfig {
|
||||||
|
|
||||||
private static final String BASE_URL =
|
|
||||||
System.getProperty("base.url", "https://otus.ru");
|
|
||||||
|
|
||||||
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() {
|
private TestConfig() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String getBaseUrl() {
|
public static String getBaseUrl() {
|
||||||
return BASE_URL;
|
return System.getProperty("base.url", "https://otus.ru");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String getCourseName() {
|
public static String getCourseName() {
|
||||||
return COURSE_NAME;
|
return System.getProperty("course.name", "Python Developer");
|
||||||
}
|
|
||||||
|
|
||||||
public static String getExecutionMode() {
|
|
||||||
return EXECUTION_MODE;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String getBrowser() {
|
public static String getBrowser() {
|
||||||
return BROWSER;
|
return System.getProperty("browser", "chrome");
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
19
src/main/java/ru/kovbasa/driver/SelectableDriverFactory.java
Normal file
19
src/main/java/ru/kovbasa/driver/SelectableDriverFactory.java
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package ru.kovbasa.driver;
|
||||||
|
|
||||||
|
import org.openqa.selenium.WebDriver;
|
||||||
|
import ru.kovbasa.config.TestConfig;
|
||||||
|
|
||||||
|
public class SelectableDriverFactory implements DriverFactory {
|
||||||
|
|
||||||
|
private final DriverFactory chromeFactory = new ChromeDriverFactory();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public WebDriver createDriver() {
|
||||||
|
final String browser = TestConfig.getBrowser().toLowerCase();
|
||||||
|
if (!"chrome".equals(browser)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Unsupported browser: " + browser + ". Supported browser: chrome");
|
||||||
|
}
|
||||||
|
return chromeFactory.createDriver();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,6 @@ package ru.kovbasa.driver;
|
|||||||
import com.google.inject.Inject;
|
import com.google.inject.Inject;
|
||||||
import org.openqa.selenium.WebDriver;
|
import org.openqa.selenium.WebDriver;
|
||||||
import org.openqa.selenium.support.events.EventFiringDecorator;
|
import org.openqa.selenium.support.events.EventFiringDecorator;
|
||||||
|
|
||||||
import io.github.bonigarcia.wdm.WebDriverManager;
|
|
||||||
import ru.kovbasa.config.TestConfig;
|
|
||||||
import ru.kovbasa.listeners.HighlightElementListener;
|
import ru.kovbasa.listeners.HighlightElementListener;
|
||||||
|
|
||||||
public final class WebDriverProvider {
|
public final class WebDriverProvider {
|
||||||
@@ -26,9 +23,6 @@ public final class WebDriverProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private WebDriver createDecoratedDriver() {
|
private WebDriver createDecoratedDriver() {
|
||||||
if (!TestConfig.isSelenoidMode()) {
|
|
||||||
WebDriverManager.chromedriver().setup();
|
|
||||||
}
|
|
||||||
final WebDriver raw = driverFactory.createDriver();
|
final WebDriver raw = driverFactory.createDriver();
|
||||||
return new EventFiringDecorator(new HighlightElementListener())
|
return new EventFiringDecorator(new HighlightElementListener())
|
||||||
.decorate(raw);
|
.decorate(raw);
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import java.time.LocalDate;
|
|||||||
import java.time.Year;
|
import java.time.Year;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
public class CourseCard extends BaseElement {
|
public class CourseCard extends BaseElement {
|
||||||
|
|
||||||
private final By titleLocator = By.cssSelector("h6 .sc-hrqzy3-1");
|
private final By titleLocator = By.cssSelector("h6 .sc-hrqzy3-1");
|
||||||
private final By dateLocator = By.cssSelector(".sc-157icee-1 .sc-hrqzy3-1");
|
private final By dateLocator = By.cssSelector(".sc-157icee-1 .sc-hrqzy3-1");
|
||||||
|
private static final Pattern PRICE_PATTERN = Pattern.compile("(\\d[\\d\\s]*)\\s*[₽р]");
|
||||||
|
|
||||||
public CourseCard(WebElement element) {
|
public CourseCard(WebElement element) {
|
||||||
super(element);
|
super(element);
|
||||||
@@ -51,4 +54,24 @@ public class CourseCard extends BaseElement {
|
|||||||
|
|
||||||
return LocalDate.parse(normalized, formatter);
|
return LocalDate.parse(normalized, formatter);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
public int price() {
|
||||||
|
final String text = element.getText();
|
||||||
|
final Matcher matcher = PRICE_PATTERN.matcher(text);
|
||||||
|
|
||||||
|
int maxPrice = -1;
|
||||||
|
while (matcher.find()) {
|
||||||
|
final String raw = matcher.group(1).replace(" ", "");
|
||||||
|
final int parsed = Integer.parseInt(raw);
|
||||||
|
if (parsed > maxPrice) {
|
||||||
|
maxPrice = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxPrice < 0) {
|
||||||
|
throw new RuntimeException("Цена курса не найдена в карточке: " + title());
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxPrice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package ru.kovbasa.listeners;
|
package ru.kovbasa.listeners;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.openqa.selenium.JavascriptExecutor;
|
import org.openqa.selenium.JavascriptExecutor;
|
||||||
import org.openqa.selenium.StaleElementReferenceException;
|
import org.openqa.selenium.StaleElementReferenceException;
|
||||||
import org.openqa.selenium.WebDriver;
|
import org.openqa.selenium.WebDriver;
|
||||||
@@ -8,6 +10,7 @@ import org.openqa.selenium.WrapsDriver;
|
|||||||
import org.openqa.selenium.support.events.WebDriverListener;
|
import org.openqa.selenium.support.events.WebDriverListener;
|
||||||
|
|
||||||
public class HighlightElementListener implements WebDriverListener {
|
public class HighlightElementListener implements WebDriverListener {
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(HighlightElementListener.class);
|
||||||
|
|
||||||
private static final String HIGHLIGHT_STYLE =
|
private static final String HIGHLIGHT_STYLE =
|
||||||
"outline: 4px solid red !important; " +
|
"outline: 4px solid red !important; " +
|
||||||
@@ -20,16 +23,11 @@ public class HighlightElementListener implements WebDriverListener {
|
|||||||
public void beforeClick(WebElement element) {
|
public void beforeClick(WebElement element) {
|
||||||
try {
|
try {
|
||||||
clearHighlight();
|
clearHighlight();
|
||||||
|
|
||||||
final String tag = safeGetTag(element);
|
|
||||||
final String href = safeGetAttr(element, "href");
|
|
||||||
final String cls = safeGetAttr(element, "class");
|
|
||||||
System.out.println("beforeClick: tag=" + tag + " href=" + href + " class=" + cls);
|
|
||||||
|
|
||||||
final WebElement tile = findTileContainer(element);
|
final WebElement tile = findTileContainer(element);
|
||||||
applyHighlight(tile);
|
applyHighlight(tile);
|
||||||
} catch (Exception e) {
|
pauseForVisibility();
|
||||||
System.out.println("Highlight listener error: " + e.getMessage());
|
} catch (RuntimeException e) {
|
||||||
|
LOG.debug("beforeClick highlight skipped", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,8 +60,8 @@ public class HighlightElementListener implements WebDriverListener {
|
|||||||
js.executeScript("arguments[0].setAttribute('style', arguments[1] + '; ' + arguments[2]);",
|
js.executeScript("arguments[0].setAttribute('style', arguments[1] + '; ' + arguments[2]);",
|
||||||
element, original, HIGHLIGHT_STYLE);
|
element, original, HIGHLIGHT_STYLE);
|
||||||
} catch (StaleElementReferenceException ignored) {
|
} catch (StaleElementReferenceException ignored) {
|
||||||
} catch (Exception e) {
|
} catch (RuntimeException e) {
|
||||||
System.out.println("applyHighlight error: " + e.getMessage());
|
LOG.debug("applyHighlight skipped", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +82,8 @@ public class HighlightElementListener implements WebDriverListener {
|
|||||||
final String original = highlightedOriginalStyle == null ? "" : highlightedOriginalStyle;
|
final String original = highlightedOriginalStyle == null ? "" : highlightedOriginalStyle;
|
||||||
js.executeScript("arguments[0].setAttribute('style', arguments[1]);", highlightedElement, original);
|
js.executeScript("arguments[0].setAttribute('style', arguments[1]);", highlightedElement, original);
|
||||||
} catch (StaleElementReferenceException ignored) {
|
} catch (StaleElementReferenceException ignored) {
|
||||||
} catch (Exception e) {
|
} catch (RuntimeException e) {
|
||||||
System.out.println("clearHighlight error: " + e.getMessage());
|
LOG.debug("clearHighlight skipped", e);
|
||||||
} finally {
|
} finally {
|
||||||
highlightedElement = null;
|
highlightedElement = null;
|
||||||
highlightedOriginalStyle = null;
|
highlightedOriginalStyle = null;
|
||||||
@@ -134,7 +132,8 @@ public class HighlightElementListener implements WebDriverListener {
|
|||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {
|
} catch (RuntimeException e) {
|
||||||
|
LOG.debug("findTileContainer fallback to original element", e);
|
||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
@@ -148,4 +147,12 @@ public class HighlightElementListener implements WebDriverListener {
|
|||||||
try { return el.getTagName(); }
|
try { return el.getTagName(); }
|
||||||
catch (Exception e) { return null; }
|
catch (Exception e) { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void pauseForVisibility() {
|
||||||
|
try {
|
||||||
|
Thread.sleep(120);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import java.time.Duration;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
|
||||||
import org.openqa.selenium.By;
|
import org.openqa.selenium.By;
|
||||||
import org.openqa.selenium.JavascriptExecutor;
|
import org.openqa.selenium.JavascriptExecutor;
|
||||||
import org.openqa.selenium.NoSuchElementException;
|
import org.openqa.selenium.NoSuchElementException;
|
||||||
import org.openqa.selenium.StaleElementReferenceException;
|
|
||||||
import org.openqa.selenium.WebDriver;
|
import org.openqa.selenium.WebDriver;
|
||||||
import org.openqa.selenium.WebElement;
|
import org.openqa.selenium.WebElement;
|
||||||
import org.openqa.selenium.interactions.Actions;
|
import org.openqa.selenium.interactions.Actions;
|
||||||
@@ -27,7 +30,11 @@ public class CatalogPage {
|
|||||||
private final WebDriver driver;
|
private final WebDriver driver;
|
||||||
|
|
||||||
private final By courseCards = By.cssSelector("a.sc-zzdkm7-0");
|
private final By courseCards = By.cssSelector("a.sc-zzdkm7-0");
|
||||||
private final By courseLinks = By.cssSelector("a[href*='/lessons/']");
|
private final By courseLinks = By.cssSelector("a[href*='/lessons/'], a[href*='/online/']");
|
||||||
|
private final By learningMenu = By.cssSelector("span[title='Обучение']");
|
||||||
|
private final By prepCoursesLink = By.xpath(
|
||||||
|
"//a[contains(normalize-space(),'Подготовительные курсы')]");
|
||||||
|
private final By showMoreButton = By.xpath("//button[contains(normalize-space(),'Показать еще')]");
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
public CatalogPage(WebDriverProvider provider) {
|
public CatalogPage(WebDriverProvider provider) {
|
||||||
@@ -36,8 +43,6 @@ public class CatalogPage {
|
|||||||
|
|
||||||
public CatalogPage open() {
|
public CatalogPage open() {
|
||||||
driver.get(TestConfig.getBaseUrl() + "/catalog/courses");
|
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;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +58,15 @@ public class CatalogPage {
|
|||||||
"Course not found in catalog by name: " + name));
|
"Course not found in catalog by name: " + name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<WebElement> findCoursesByName(String name) {
|
||||||
|
final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15));
|
||||||
|
wait.until((ExpectedCondition<Boolean>) drv -> !drv.findElements(courseLinks).isEmpty());
|
||||||
|
|
||||||
|
return driver.findElements(courseLinks).stream()
|
||||||
|
.filter(e -> e.getText().toLowerCase().contains(name.toLowerCase()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
public CoursePage clickCourseByName(String name) {
|
public CoursePage clickCourseByName(String name) {
|
||||||
PageUtils.removeBottomBanner(driver);
|
PageUtils.removeBottomBanner(driver);
|
||||||
|
|
||||||
@@ -69,39 +83,44 @@ public class CatalogPage {
|
|||||||
return new CoursePage(driver);
|
return new CoursePage(driver);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CoursePage clickRandomCourseByName(String name) {
|
||||||
|
PageUtils.removeBottomBanner(driver);
|
||||||
|
|
||||||
|
final List<WebElement> courses = findCoursesByName(name);
|
||||||
|
if (courses.isEmpty()) {
|
||||||
|
throw new NoSuchElementException("Course not found in catalog by name: " + name);
|
||||||
|
}
|
||||||
|
|
||||||
|
final WebElement chosen = courses.get(ThreadLocalRandom.current().nextInt(courses.size()));
|
||||||
|
|
||||||
|
((JavascriptExecutor) driver)
|
||||||
|
.executeScript("arguments[0].scrollIntoView({block:'center'});", chosen);
|
||||||
|
|
||||||
|
new Actions(driver)
|
||||||
|
.moveToElement(chosen)
|
||||||
|
.perform();
|
||||||
|
chosen.click();
|
||||||
|
|
||||||
|
return new CoursePage(driver);
|
||||||
|
}
|
||||||
|
|
||||||
public List<CourseCard> getAllCourseCards() {
|
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()
|
return driver.findElements(courseCards).stream()
|
||||||
.map(CourseCard::new)
|
.map(CourseCard::new)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<CourseItem> getAllCourses() {
|
public List<CourseItem> getAllCourses() {
|
||||||
final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(20));
|
return getAllCourseCards().stream()
|
||||||
wait.until((ExpectedCondition<Boolean>) drv -> !drv.findElements(courseCards).isEmpty());
|
.map(card -> {
|
||||||
|
try {
|
||||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
return new CourseItem(card.title(), card.startDate());
|
||||||
try {
|
} catch (RuntimeException e) {
|
||||||
final List<CourseItem> parsedCourses = new ArrayList<>();
|
return null;
|
||||||
for (WebElement cardElement : driver.findElements(courseCards)) {
|
|
||||||
final CourseItem item = toCourseItem(new CourseCard(cardElement));
|
|
||||||
if (item != null) {
|
|
||||||
parsedCourses.add(item);
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
.filter(Objects::nonNull)
|
||||||
if (!parsedCourses.isEmpty()) {
|
.toList();
|
||||||
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() {
|
public List<CourseItem> findEarliestCourses() {
|
||||||
@@ -109,9 +128,8 @@ public class CatalogPage {
|
|||||||
|
|
||||||
final LocalDate minDate = all.stream()
|
final LocalDate minDate = all.stream()
|
||||||
.map(CourseItem::startDate)
|
.map(CourseItem::startDate)
|
||||||
.min(LocalDate::compareTo)
|
.reduce((left, right) -> left.isBefore(right) ? left : right)
|
||||||
.orElseThrow(() -> new IllegalStateException(
|
.orElseThrow();
|
||||||
"Unable to determine earliest date from parsed courses"));
|
|
||||||
|
|
||||||
return all.stream()
|
return all.stream()
|
||||||
.filter(c -> c.startDate().isEqual(minDate))
|
.filter(c -> c.startDate().isEqual(minDate))
|
||||||
@@ -124,9 +142,8 @@ public class CatalogPage {
|
|||||||
|
|
||||||
final LocalDate maxDate = all.stream()
|
final LocalDate maxDate = all.stream()
|
||||||
.map(CourseItem::startDate)
|
.map(CourseItem::startDate)
|
||||||
.max(LocalDate::compareTo)
|
.reduce((left, right) -> left.isAfter(right) ? left : right)
|
||||||
.orElseThrow(() -> new IllegalStateException(
|
.orElseThrow();
|
||||||
"Unable to determine latest date from parsed courses"));
|
|
||||||
|
|
||||||
return all.stream()
|
return all.stream()
|
||||||
.filter(c -> c.startDate().isEqual(maxDate))
|
.filter(c -> c.startDate().isEqual(maxDate))
|
||||||
@@ -134,48 +151,205 @@ public class CatalogPage {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<CourseItem> findCoursesStartingFrom(LocalDate dateFrom) {
|
||||||
|
return getAllCourses().stream()
|
||||||
|
.filter(course -> !course.startDate().isBefore(dateFrom))
|
||||||
|
.sorted(Comparator.comparing(CourseItem::startDate).thenComparing(CourseItem::title))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public CatalogPage openPreparatoryCourses() {
|
||||||
|
driver.get(TestConfig.getBaseUrl());
|
||||||
|
PageUtils.removeBottomBanner(driver);
|
||||||
|
|
||||||
|
final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15));
|
||||||
|
final WebElement menu = wait.until(drv -> drv.findElement(learningMenu));
|
||||||
|
menu.click();
|
||||||
|
|
||||||
|
final WebElement prep = wait.until(drv -> drv.findElements(prepCoursesLink).stream()
|
||||||
|
.filter(WebElement::isDisplayed)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null));
|
||||||
|
|
||||||
|
((JavascriptExecutor) driver)
|
||||||
|
.executeScript("arguments[0].scrollIntoView({block:'center'});", prep);
|
||||||
|
prep.click();
|
||||||
|
|
||||||
|
wait.until(drv -> drv.findElements(
|
||||||
|
By.xpath("//*[contains(normalize-space(),'Подготовительные курсы')]")
|
||||||
|
).stream().anyMatch(WebElement::isDisplayed));
|
||||||
|
|
||||||
|
expandAllCourses();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<PricedCourseItem> getAllCoursesWithPrice() {
|
||||||
|
return getAllCourseCards().stream()
|
||||||
|
.map(card -> {
|
||||||
|
try {
|
||||||
|
return new PricedCourseItem(card.title(), card.price());
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<CourseLinkItem> getPreparatoryCourseLinks() {
|
||||||
|
final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15));
|
||||||
|
wait.until((ExpectedCondition<Boolean>) drv -> !drv.findElements(courseLinks).isEmpty());
|
||||||
|
|
||||||
|
final Map<String, String> titleByUrl = new LinkedHashMap<>();
|
||||||
|
for (WebElement link : driver.findElements(courseLinks)) {
|
||||||
|
if (!link.isDisplayed()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final String title = link.getText().trim();
|
||||||
|
final String url = link.getAttribute("href");
|
||||||
|
if (title.isEmpty() || url == null || !isCourseUrl(url)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
titleByUrl.putIfAbsent(url, title);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Map.Entry<String, String>> onlineEntries = titleByUrl.entrySet().stream()
|
||||||
|
.filter(entry -> entry.getKey().contains("/online/"))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final List<CourseLinkItem> items = new ArrayList<>();
|
||||||
|
if (!onlineEntries.isEmpty()) {
|
||||||
|
onlineEntries.forEach(entry -> items.add(new CourseLinkItem(cleanTitle(entry.getValue()), entry.getKey())));
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
titleByUrl.forEach((url, title) -> items.add(new CourseLinkItem(cleanTitle(title), url)));
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<PricedCourseItem> getPreparatoryCoursesWithDiscountedFullPrice() {
|
||||||
|
final List<CourseLinkItem> links = getPreparatoryCourseLinks();
|
||||||
|
return links.stream()
|
||||||
|
.map(link -> {
|
||||||
|
driver.get(link.url());
|
||||||
|
final CoursePage coursePage = new CoursePage(driver);
|
||||||
|
final int price = coursePage.getPriceForComparison();
|
||||||
|
String title = link.title();
|
||||||
|
try {
|
||||||
|
title = coursePage.getCourseTitle();
|
||||||
|
} catch (RuntimeException ignored) { }
|
||||||
|
return new PricedCourseItem(title, price);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<CourseLinkItem> getCatalogLessonCourseLinks() {
|
||||||
|
open();
|
||||||
|
|
||||||
|
final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15));
|
||||||
|
wait.until((ExpectedCondition<Boolean>) drv -> !drv.findElements(courseLinks).isEmpty());
|
||||||
|
|
||||||
|
final Map<String, String> titleByUrl = new LinkedHashMap<>();
|
||||||
|
for (WebElement link : driver.findElements(courseLinks)) {
|
||||||
|
if (!link.isDisplayed()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final String title = link.getText().trim();
|
||||||
|
final String url = link.getAttribute("href");
|
||||||
|
if (title.isEmpty() || url == null || !url.contains("/lessons/")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
titleByUrl.putIfAbsent(url, cleanTitle(title));
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<CourseLinkItem> items = new ArrayList<>();
|
||||||
|
titleByUrl.forEach((url, title) -> items.add(new CourseLinkItem(title, url)));
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<PricedCourseItem> getCatalogCoursesWithDiscountedFullPrice() {
|
||||||
|
final List<CourseLinkItem> links = getCatalogLessonCourseLinks();
|
||||||
|
return links.stream()
|
||||||
|
.map(link -> {
|
||||||
|
driver.get(link.url());
|
||||||
|
final CoursePage coursePage = new CoursePage(driver);
|
||||||
|
final int price = coursePage.getPriceForComparison();
|
||||||
|
String title = link.title();
|
||||||
|
try {
|
||||||
|
title = coursePage.getCourseTitle();
|
||||||
|
} catch (RuntimeException ignored) { }
|
||||||
|
return new PricedCourseItem(title, price);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void expandAllCourses() {
|
||||||
|
final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(5));
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
final WebElement button = wait.until(drv -> drv.findElements(showMoreButton).stream()
|
||||||
|
.filter(WebElement::isDisplayed)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null));
|
||||||
|
|
||||||
|
((JavascriptExecutor) driver)
|
||||||
|
.executeScript("arguments[0].scrollIntoView({block:'center'});", button);
|
||||||
|
button.click();
|
||||||
|
wait.until(drv -> !drv.findElements(courseLinks).isEmpty());
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isCourseUrl(String url) {
|
||||||
|
return url.contains("/lessons/") || url.contains("/online/");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String cleanTitle(String rawTitle) {
|
||||||
|
final String[] lines = rawTitle.split("\\R");
|
||||||
|
for (int i = lines.length - 1; i >= 0; i--) {
|
||||||
|
final String line = lines[i].trim();
|
||||||
|
if (!line.isEmpty()) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rawTitle.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public PricedCourseItem findMostExpensiveCourse() {
|
||||||
|
return getAllCoursesWithPrice().stream()
|
||||||
|
.reduce((left, right) -> left.price() >= right.price() ? left : right)
|
||||||
|
.orElseThrow(() -> new NoSuchElementException("Priced course cards are not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public PricedCourseItem findCheapestCourse() {
|
||||||
|
return getAllCoursesWithPrice().stream()
|
||||||
|
.reduce((left, right) -> left.price() <= right.price() ? left : right)
|
||||||
|
.orElseThrow(() -> new NoSuchElementException("Priced course cards are not found"));
|
||||||
|
}
|
||||||
|
|
||||||
public CoursePage openCourse(CourseItem course) {
|
public CoursePage openCourse(CourseItem course) {
|
||||||
PageUtils.removeBottomBanner(driver);
|
PageUtils.removeBottomBanner(driver);
|
||||||
|
|
||||||
final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(12));
|
final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
|
||||||
wait.until((ExpectedCondition<Boolean>) drv -> !drv.findElements(courseCards).isEmpty());
|
|
||||||
|
|
||||||
for (int attempt = 1; attempt <= 5; attempt++) {
|
final WebElement card = wait.until(drv ->
|
||||||
try {
|
drv.findElements(courseCards).stream()
|
||||||
for (WebElement card : driver.findElements(courseCards)) {
|
.filter(c -> new CourseCard(c).title().equals(course.title()))
|
||||||
final CourseCard courseCard = new CourseCard(card);
|
.findFirst()
|
||||||
final String candidateTitle;
|
.orElse(null)
|
||||||
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)
|
((JavascriptExecutor) driver)
|
||||||
.executeScript("arguments[0].scrollIntoView({block:'center'});", card);
|
.executeScript("arguments[0].scrollIntoView({block:'center'});", card);
|
||||||
|
|
||||||
new Actions(driver).moveToElement(card).perform();
|
new Actions(driver)
|
||||||
card.click();
|
.moveToElement(card)
|
||||||
return new CoursePage(driver);
|
.perform();
|
||||||
}
|
card.click();
|
||||||
} catch (StaleElementReferenceException ignored) {
|
|
||||||
// Re-query cards when DOM updates during search/click.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new NoSuchElementException("Course card not found for title: " + course.title());
|
return new CoursePage(driver);
|
||||||
}
|
|
||||||
|
|
||||||
private CourseItem toCourseItem(CourseCard card) {
|
|
||||||
try {
|
|
||||||
return new CourseItem(card.title(), card.startDate());
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
src/main/java/ru/kovbasa/pages/CourseLinkItem.java
Normal file
4
src/main/java/ru/kovbasa/pages/CourseLinkItem.java
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
package ru.kovbasa.pages;
|
||||||
|
|
||||||
|
public record CourseLinkItem(String title, String url) {
|
||||||
|
}
|
||||||
@@ -4,18 +4,31 @@ import org.jsoup.Jsoup;
|
|||||||
import org.jsoup.nodes.Document;
|
import org.jsoup.nodes.Document;
|
||||||
import org.jsoup.nodes.Element;
|
import org.jsoup.nodes.Element;
|
||||||
import org.openqa.selenium.By;
|
import org.openqa.selenium.By;
|
||||||
|
import org.openqa.selenium.JavascriptExecutor;
|
||||||
|
import org.openqa.selenium.NoSuchElementException;
|
||||||
import org.openqa.selenium.WebDriver;
|
import org.openqa.selenium.WebDriver;
|
||||||
|
import org.openqa.selenium.WebElement;
|
||||||
|
import org.openqa.selenium.interactions.Actions;
|
||||||
|
import org.openqa.selenium.support.ui.WebDriverWait;
|
||||||
import ru.kovbasa.elements.Button;
|
import ru.kovbasa.elements.Button;
|
||||||
|
import ru.kovbasa.utils.PageUtils;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
public class CoursePage {
|
public class CoursePage {
|
||||||
|
private static final Pattern PRICE_PATTERN = Pattern.compile("(\\d[\\d\\s\\u00A0]*)\\s*₽");
|
||||||
|
|
||||||
|
|
||||||
private final WebDriver driver;
|
private final WebDriver driver;
|
||||||
|
|
||||||
private final By enrollButton = By.cssSelector("button[data-testid='enroll-button']");
|
private final By enrollButton = By.cssSelector("button[data-testid='enroll-button']");
|
||||||
|
private final By fullPriceTab = By.xpath("//div[normalize-space()='Полная']/parent::div");
|
||||||
|
private final By fullDiscountLabel = By.xpath("//p[contains(normalize-space(),'Полная стоимость со скидкой')]");
|
||||||
|
private final By anyPriceText = By.xpath("//*[contains(normalize-space(),'₽')]");
|
||||||
|
|
||||||
public CoursePage(WebDriver driver) {
|
public CoursePage(WebDriver driver) {
|
||||||
this.driver = driver;
|
this.driver = driver;
|
||||||
@@ -59,4 +72,68 @@ public class CoursePage {
|
|||||||
public void clickEnroll() {
|
public void clickEnroll() {
|
||||||
getEnrollButton().click();
|
getEnrollButton().click();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
public int getDiscountedFullPrice() {
|
||||||
|
PageUtils.removeBottomBanner(driver);
|
||||||
|
|
||||||
|
final WebDriverWait wait = new WebDriverWait(driver, java.time.Duration.ofSeconds(10));
|
||||||
|
final WebElement tab = wait.until(drv -> drv.findElements(fullPriceTab).stream()
|
||||||
|
.filter(WebElement::isDisplayed)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null));
|
||||||
|
|
||||||
|
((JavascriptExecutor) driver).executeScript("arguments[0].scrollIntoView({block:'center'});", tab);
|
||||||
|
try {
|
||||||
|
tab.click();
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
new Actions(driver).moveToElement(tab).click().perform();
|
||||||
|
}
|
||||||
|
|
||||||
|
final WebElement label = wait.until(drv -> drv.findElements(fullDiscountLabel).stream()
|
||||||
|
.filter(WebElement::isDisplayed)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null));
|
||||||
|
|
||||||
|
final WebElement priceElement = findPriceElementNearLabel(label);
|
||||||
|
final String rawPrice = priceElement.getText();
|
||||||
|
return parsePrice(rawPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPriceForComparison() {
|
||||||
|
if (hasDisplayed(fullPriceTab)) {
|
||||||
|
try {
|
||||||
|
return getDiscountedFullPrice();
|
||||||
|
} catch (RuntimeException ignored) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
final WebDriverWait wait = new WebDriverWait(driver, java.time.Duration.ofSeconds(10));
|
||||||
|
final WebElement priceElement = wait.until(drv -> drv.findElements(anyPriceText).stream()
|
||||||
|
.filter(WebElement::isDisplayed)
|
||||||
|
.filter(el -> PRICE_PATTERN.matcher(el.getText()).find())
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null));
|
||||||
|
|
||||||
|
return parsePrice(priceElement.getText());
|
||||||
|
}
|
||||||
|
|
||||||
|
private WebElement findPriceElementNearLabel(WebElement label) {
|
||||||
|
try {
|
||||||
|
return label.findElement(By.xpath("following-sibling::div[1]"));
|
||||||
|
} catch (NoSuchElementException ignored) {
|
||||||
|
return label.findElement(By.xpath("following::div[contains(normalize-space(),'₽')][1]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int parsePrice(String rawPrice) {
|
||||||
|
final Matcher matcher = PRICE_PATTERN.matcher(rawPrice);
|
||||||
|
if (!matcher.find()) {
|
||||||
|
throw new IllegalArgumentException("Не удалось распарсить цену из строки: " + rawPrice);
|
||||||
|
}
|
||||||
|
final String normalized = matcher.group(1).replace('\u00A0', ' ').replace(" ", "");
|
||||||
|
return Integer.parseInt(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasDisplayed(By by) {
|
||||||
|
return driver.findElements(by).stream().anyMatch(WebElement::isDisplayed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import com.google.inject.Inject;
|
|||||||
import org.openqa.selenium.By;
|
import org.openqa.selenium.By;
|
||||||
import org.openqa.selenium.ElementClickInterceptedException;
|
import org.openqa.selenium.ElementClickInterceptedException;
|
||||||
import org.openqa.selenium.JavascriptExecutor;
|
import org.openqa.selenium.JavascriptExecutor;
|
||||||
import org.openqa.selenium.NoSuchElementException;
|
|
||||||
import org.openqa.selenium.WebDriver;
|
import org.openqa.selenium.WebDriver;
|
||||||
import org.openqa.selenium.WebElement;
|
import org.openqa.selenium.WebElement;
|
||||||
import org.openqa.selenium.interactions.Actions;
|
import org.openqa.selenium.interactions.Actions;
|
||||||
@@ -21,8 +20,7 @@ public class MainPage {
|
|||||||
|
|
||||||
private final WebDriver driver;
|
private final WebDriver driver;
|
||||||
|
|
||||||
private final By menuLearningByTitle = By.cssSelector("span[title='Обучение']");
|
private final By menuLearning = By.cssSelector("span[title='Обучение']");
|
||||||
private final By menuLearningByText = By.xpath("//span[normalize-space()='Обучение']");
|
|
||||||
private final By categories = By.cssSelector("a[href*='/categories/']");
|
private final By categories = By.cssSelector("a[href*='/categories/']");
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@@ -38,22 +36,20 @@ public class MainPage {
|
|||||||
public String clickRandomCategory() {
|
public String clickRandomCategory() {
|
||||||
PageUtils.removeBottomBanner(driver);
|
PageUtils.removeBottomBanner(driver);
|
||||||
|
|
||||||
final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(20));
|
final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
|
||||||
|
|
||||||
List<WebElement> els = findVisibleCategories(wait);
|
final WebElement menu = wait.until(drv -> drv.findElement(menuLearning));
|
||||||
if (els.isEmpty()) {
|
menu.click();
|
||||||
clickLearningMenu(wait);
|
|
||||||
els = wait.until(drv -> {
|
final List<WebElement> els = wait.until(drv -> {
|
||||||
final List<WebElement> found = drv.findElements(categories).stream()
|
final List<WebElement> found = drv.findElements(categories).stream()
|
||||||
.filter(WebElement::isDisplayed)
|
.filter(WebElement::isDisplayed)
|
||||||
.toList();
|
.toList();
|
||||||
return found.isEmpty() ? null : found;
|
return found.isEmpty() ? null : found;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
final WebElement chosen = els.get(ThreadLocalRandom.current().nextInt(els.size()));
|
final WebElement chosen = els.get(ThreadLocalRandom.current().nextInt(els.size()));
|
||||||
final String href = chosen.getAttribute("href");
|
final String href = chosen.getAttribute("href");
|
||||||
System.out.println("Selected category href = " + href);
|
|
||||||
|
|
||||||
((JavascriptExecutor) driver)
|
((JavascriptExecutor) driver)
|
||||||
.executeScript("arguments[0].scrollIntoView({block:'center'});", chosen);
|
.executeScript("arguments[0].scrollIntoView({block:'center'});", chosen);
|
||||||
@@ -71,29 +67,4 @@ public class MainPage {
|
|||||||
|
|
||||||
return href;
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
4
src/main/java/ru/kovbasa/pages/PricedCourseItem.java
Normal file
4
src/main/java/ru/kovbasa/pages/PricedCourseItem.java
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
package ru.kovbasa.pages;
|
||||||
|
|
||||||
|
public record PricedCourseItem(String title, int price) {
|
||||||
|
}
|
||||||
17
src/test/java/ru/kovbasa/bdd/CucumberTest.java
Normal file
17
src/test/java/ru/kovbasa/bdd/CucumberTest.java
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package ru.kovbasa.bdd;
|
||||||
|
|
||||||
|
import org.junit.platform.suite.api.ConfigurationParameter;
|
||||||
|
import org.junit.platform.suite.api.IncludeEngines;
|
||||||
|
import org.junit.platform.suite.api.SelectClasspathResource;
|
||||||
|
import org.junit.platform.suite.api.Suite;
|
||||||
|
|
||||||
|
import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME;
|
||||||
|
import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME;
|
||||||
|
|
||||||
|
@Suite
|
||||||
|
@IncludeEngines("cucumber")
|
||||||
|
@SelectClasspathResource("features")
|
||||||
|
@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "ru.kovbasa.bdd")
|
||||||
|
@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty")
|
||||||
|
public class CucumberTest {
|
||||||
|
}
|
||||||
15
src/test/java/ru/kovbasa/bdd/hooks/Hooks.java
Normal file
15
src/test/java/ru/kovbasa/bdd/hooks/Hooks.java
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package ru.kovbasa.bdd.hooks;
|
||||||
|
|
||||||
|
import com.google.inject.Injector;
|
||||||
|
import io.cucumber.java.After;
|
||||||
|
import ru.kovbasa.config.InjectorProvider;
|
||||||
|
import ru.kovbasa.driver.WebDriverProvider;
|
||||||
|
|
||||||
|
public class Hooks {
|
||||||
|
|
||||||
|
@After
|
||||||
|
public void afterScenario() {
|
||||||
|
final Injector injector = InjectorProvider.getInjector();
|
||||||
|
injector.getInstance(WebDriverProvider.class).quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
159
src/test/java/ru/kovbasa/bdd/steps/CatalogSteps.java
Normal file
159
src/test/java/ru/kovbasa/bdd/steps/CatalogSteps.java
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package ru.kovbasa.bdd.steps;
|
||||||
|
|
||||||
|
import com.google.inject.Injector;
|
||||||
|
import io.cucumber.java.ru.Дано;
|
||||||
|
import io.cucumber.java.ru.И;
|
||||||
|
import io.cucumber.java.ru.Когда;
|
||||||
|
import io.cucumber.java.ru.Тогда;
|
||||||
|
import org.junit.jupiter.api.Assertions;
|
||||||
|
import ru.kovbasa.config.InjectorProvider;
|
||||||
|
import ru.kovbasa.pages.CatalogPage;
|
||||||
|
import ru.kovbasa.pages.CourseItem;
|
||||||
|
import ru.kovbasa.pages.CoursePage;
|
||||||
|
import ru.kovbasa.pages.PricedCourseItem;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
public class CatalogSteps {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter DATE_FORMATTER =
|
||||||
|
DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.forLanguageTag("ru"));
|
||||||
|
|
||||||
|
private CoursePage openedCoursePage;
|
||||||
|
private List<CourseItem> coursesStartingFromDate;
|
||||||
|
private LocalDate searchDate;
|
||||||
|
private PricedCourseItem mostExpensiveCourse;
|
||||||
|
private PricedCourseItem cheapestCourse;
|
||||||
|
private PricedCourseItem mostExpensiveCatalogCourse;
|
||||||
|
private PricedCourseItem cheapestCatalogCourse;
|
||||||
|
|
||||||
|
@Дано("Открыт браузер {word}")
|
||||||
|
public void openBrowser(String browser) {
|
||||||
|
System.setProperty("browser", browser.toLowerCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Когда("Открыт каталог курсов")
|
||||||
|
public void openCatalog() {
|
||||||
|
getCatalogPage().open();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Когда("Найден курс {string} и выбран случайный из найденных")
|
||||||
|
public void findCourseAndOpenRandom(String courseName) {
|
||||||
|
openedCoursePage = getCatalogPage().clickRandomCourseByName(courseName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Тогда("Заголовок страницы курса содержит {string}")
|
||||||
|
public void coursePageTitleContains(String expectedCourseName) {
|
||||||
|
final String title = openedCoursePage.getCourseTitle();
|
||||||
|
Assertions.assertTrue(
|
||||||
|
title.toLowerCase(Locale.ROOT).contains(expectedCourseName.toLowerCase(Locale.ROOT)),
|
||||||
|
"Заголовок страницы курса должен содержать: " + expectedCourseName + ", фактически: " + title
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Когда("Найдены курсы со стартом {string} или позже")
|
||||||
|
public void findCoursesFromDate(String date) {
|
||||||
|
searchDate = LocalDate.parse(date, DATE_FORMATTER);
|
||||||
|
coursesStartingFromDate = getCatalogPage().findCoursesStartingFrom(searchDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Тогда("В консоль выведены найденные курсы и даты старта")
|
||||||
|
public void printCoursesAndStartDates() {
|
||||||
|
Assertions.assertFalse(coursesStartingFromDate.isEmpty(), "Не найдено курсов по заданной дате");
|
||||||
|
|
||||||
|
coursesStartingFromDate.forEach(course -> System.out.println(
|
||||||
|
"Курс: " + course.title() + ", дата старта: " + course.startDate().format(DATE_FORMATTER)
|
||||||
|
));
|
||||||
|
|
||||||
|
final boolean allAfterOrEqual = coursesStartingFromDate.stream()
|
||||||
|
.allMatch(course -> !course.startDate().isBefore(searchDate));
|
||||||
|
|
||||||
|
Assertions.assertTrue(
|
||||||
|
allAfterOrEqual,
|
||||||
|
"Среди найденных есть курсы с датой старта раньше " + searchDate.format(DATE_FORMATTER)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Когда("Открыт раздел Обучение и Подготовительные курсы")
|
||||||
|
public void openPreparatoryCourses() {
|
||||||
|
getCatalogPage().openPreparatoryCourses();
|
||||||
|
}
|
||||||
|
|
||||||
|
@И("Выбраны самый дорогой и самый дешевый курсы с помощью filter")
|
||||||
|
public void findMostExpensiveAndCheapestByFilter() {
|
||||||
|
final List<PricedCourseItem> pricedCourses = getCatalogPage().getPreparatoryCoursesWithDiscountedFullPrice();
|
||||||
|
Assertions.assertFalse(pricedCourses.isEmpty(), "Не найдено курсов с ценой");
|
||||||
|
|
||||||
|
final int maxPrice = pricedCourses.stream().mapToInt(PricedCourseItem::price).max().orElseThrow();
|
||||||
|
final int minPrice = pricedCourses.stream().mapToInt(PricedCourseItem::price).min().orElseThrow();
|
||||||
|
|
||||||
|
mostExpensiveCourse = pricedCourses.stream()
|
||||||
|
.filter(course -> course.price() == maxPrice)
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow();
|
||||||
|
|
||||||
|
cheapestCourse = pricedCourses.stream()
|
||||||
|
.filter(course -> course.price() == minPrice)
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Тогда("В консоль выведена информация о самом дорогом и самом дешевом курсе")
|
||||||
|
public void printMostExpensiveAndCheapestCourse() {
|
||||||
|
System.out.println(
|
||||||
|
"Самый дорогой курс: " + mostExpensiveCourse.title() + ", цена: " + mostExpensiveCourse.price()
|
||||||
|
);
|
||||||
|
System.out.println(
|
||||||
|
"Самый дешевый курс: " + cheapestCourse.title() + ", цена: " + cheapestCourse.price()
|
||||||
|
);
|
||||||
|
|
||||||
|
Assertions.assertTrue(
|
||||||
|
mostExpensiveCourse.price() >= cheapestCourse.price(),
|
||||||
|
"Цена самого дорогого курса должна быть не меньше цены самого дешевого"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Когда("В каталоге курсов выбраны самый дорогой и самый дешевый курсы по полной стоимости со скидкой с помощью filter")
|
||||||
|
public void findMostExpensiveAndCheapestInCatalogByFullDiscountedPrice() {
|
||||||
|
final List<PricedCourseItem> pricedCourses = getCatalogPage().getCatalogCoursesWithDiscountedFullPrice();
|
||||||
|
Assertions.assertFalse(pricedCourses.isEmpty(), "Не найдено курсов с ценой в общем каталоге");
|
||||||
|
|
||||||
|
final int maxPrice = pricedCourses.stream().mapToInt(PricedCourseItem::price).max().orElseThrow();
|
||||||
|
final int minPrice = pricedCourses.stream().mapToInt(PricedCourseItem::price).min().orElseThrow();
|
||||||
|
|
||||||
|
mostExpensiveCatalogCourse = pricedCourses.stream()
|
||||||
|
.filter(course -> course.price() == maxPrice)
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow();
|
||||||
|
|
||||||
|
cheapestCatalogCourse = pricedCourses.stream()
|
||||||
|
.filter(course -> course.price() == minPrice)
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Тогда("В консоль выведена информация о самом дорогом и самом дешевом курсе в каталоге")
|
||||||
|
public void printMostExpensiveAndCheapestCourseInCatalog() {
|
||||||
|
System.out.println(
|
||||||
|
"Самый дорогой курс в каталоге: "
|
||||||
|
+ mostExpensiveCatalogCourse.title() + ", цена: " + mostExpensiveCatalogCourse.price()
|
||||||
|
);
|
||||||
|
System.out.println(
|
||||||
|
"Самый дешевый курс в каталоге: "
|
||||||
|
+ cheapestCatalogCourse.title() + ", цена: " + cheapestCatalogCourse.price()
|
||||||
|
);
|
||||||
|
|
||||||
|
Assertions.assertTrue(
|
||||||
|
mostExpensiveCatalogCourse.price() >= cheapestCatalogCourse.price(),
|
||||||
|
"Цена самого дорогого курса в каталоге должна быть не меньше цены самого дешевого"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CatalogPage getCatalogPage() {
|
||||||
|
final Injector injector = InjectorProvider.getInjector();
|
||||||
|
return injector.getInstance(CatalogPage.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package ru.kovbasa.config;
|
|
||||||
|
|
||||||
import com.google.inject.Injector;
|
|
||||||
import org.junit.jupiter.api.extension.AfterEachCallback;
|
|
||||||
import org.junit.jupiter.api.extension.BeforeEachCallback;
|
|
||||||
import org.junit.jupiter.api.extension.ExtensionContext;
|
|
||||||
import org.junit.jupiter.api.extension.TestInstancePostProcessor;
|
|
||||||
import ru.kovbasa.driver.WebDriverProvider;
|
|
||||||
|
|
||||||
public class GuiceExtension implements TestInstancePostProcessor, BeforeEachCallback, AfterEachCallback {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void postProcessTestInstance(Object testInstance, ExtensionContext context) {
|
|
||||||
final Injector injector = InjectorProvider.getInjector();
|
|
||||||
injector.injectMembers(testInstance);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void beforeEach(ExtensionContext context) {
|
|
||||||
final Injector injector = InjectorProvider.getInjector();
|
|
||||||
injector.getInstance(WebDriverProvider.class).getDriver();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void afterEach(ExtensionContext context) {
|
|
||||||
final Injector injector = InjectorProvider.getInjector();
|
|
||||||
injector.getInstance(WebDriverProvider.class).quit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package ru.kovbasa.tests;
|
|
||||||
|
|
||||||
import com.google.inject.Inject;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import ru.kovbasa.config.GuiceExtension;
|
|
||||||
import ru.kovbasa.driver.WebDriverProvider;
|
|
||||||
import ru.kovbasa.pages.MainPage;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
@ExtendWith(GuiceExtension.class)
|
|
||||||
public class CategoryRandomTest {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
private MainPage main;
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
private WebDriverProvider webDriverProvider;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void randomCategoryOpensCorrectCatalog() {
|
|
||||||
main.open();
|
|
||||||
|
|
||||||
final String selectedHref = main.clickRandomCategory();
|
|
||||||
final String category = selectedHref.substring(selectedHref.lastIndexOf("/") + 1);
|
|
||||||
final String currentUrl = webDriverProvider.getDriver().getCurrentUrl();
|
|
||||||
|
|
||||||
assertTrue(
|
|
||||||
currentUrl.contains(category),
|
|
||||||
"Catalog URL should contain selected category. Selected: "
|
|
||||||
+ selectedHref + ", current: " + currentUrl
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package ru.kovbasa.tests;
|
|
||||||
|
|
||||||
import com.google.inject.Inject;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import ru.kovbasa.config.GuiceExtension;
|
|
||||||
import ru.kovbasa.config.TestConfig;
|
|
||||||
import ru.kovbasa.pages.CatalogPage;
|
|
||||||
import ru.kovbasa.pages.CoursePage;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
@ExtendWith(GuiceExtension.class)
|
|
||||||
public class CourseSearchTest {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
private CatalogPage catalog;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findCourseByName() {
|
|
||||||
catalog.open();
|
|
||||||
|
|
||||||
final String courseName = TestConfig.getCourseName();
|
|
||||||
|
|
||||||
final CoursePage page = catalog.clickCourseByName(courseName);
|
|
||||||
|
|
||||||
final String title = page.getCourseTitle();
|
|
||||||
assertTrue(
|
|
||||||
title.toLowerCase().contains(courseName.toLowerCase()),
|
|
||||||
"Course page title should contain searched course name"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
package ru.kovbasa.tests;
|
|
||||||
|
|
||||||
import com.google.inject.Inject;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import ru.kovbasa.config.GuiceExtension;
|
|
||||||
import ru.kovbasa.pages.CatalogPage;
|
|
||||||
import ru.kovbasa.pages.CourseItem;
|
|
||||||
import ru.kovbasa.pages.CoursePage;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
@ExtendWith(GuiceExtension.class)
|
|
||||||
public class CoursesDatesTest {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
private CatalogPage catalog;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void earliestCourseHasCorrectTitleAndDate() {
|
|
||||||
catalog.open();
|
|
||||||
|
|
||||||
final List<CourseItem> earliestCourses = catalog.findEarliestCourses();
|
|
||||||
|
|
||||||
for (CourseItem course : earliestCourses) {
|
|
||||||
final CoursePage page = catalog.openCourse(course);
|
|
||||||
|
|
||||||
final String pageTitle = page.getCourseTitle();
|
|
||||||
assertTrue(
|
|
||||||
pageTitle.toLowerCase().contains(course.title().toLowerCase()),
|
|
||||||
"Earliest course title on page should contain title from catalog: " + course.title()
|
|
||||||
);
|
|
||||||
|
|
||||||
final LocalDate pageDate = page.getCourseStartDate(course.startDate());
|
|
||||||
assertEquals(
|
|
||||||
course.startDate(),
|
|
||||||
pageDate,
|
|
||||||
"Earliest course start date should match for course: " + course.title()
|
|
||||||
);
|
|
||||||
|
|
||||||
catalog.open();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void latestCourseHasCorrectTitleAndDate() {
|
|
||||||
catalog.open();
|
|
||||||
|
|
||||||
final List<CourseItem> latestCourses = catalog.findLatestCourses();
|
|
||||||
|
|
||||||
for (CourseItem course : latestCourses) {
|
|
||||||
final CoursePage page = catalog.openCourse(course);
|
|
||||||
|
|
||||||
final String pageTitle = page.getCourseTitle();
|
|
||||||
assertTrue(
|
|
||||||
pageTitle.toLowerCase().contains(course.title().toLowerCase()),
|
|
||||||
"Latest course title on page should contain title from catalog: " + course.title()
|
|
||||||
);
|
|
||||||
|
|
||||||
final LocalDate pageDate = page.getCourseStartDate(course.startDate());
|
|
||||||
assertEquals(
|
|
||||||
course.startDate(),
|
|
||||||
pageDate,
|
|
||||||
"Latest course start date should match for course: " + course.title()
|
|
||||||
);
|
|
||||||
|
|
||||||
catalog.open();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
29
src/test/resources/features/catalog.feature
Normal file
29
src/test/resources/features/catalog.feature
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# language: ru
|
||||||
|
|
||||||
|
Функция: Каталог курсов OTUS
|
||||||
|
Как пользователь
|
||||||
|
Я хочу работать с каталогом курсов через BDD
|
||||||
|
Чтобы проверять поиск, фильтрацию по датам и выбор по цене
|
||||||
|
|
||||||
|
Сценарий: Выбор браузера и поиск курса по названию со случайным выбором
|
||||||
|
Дано Открыт браузер Chrome
|
||||||
|
Когда Открыт каталог курсов
|
||||||
|
И Найден курс "Python" и выбран случайный из найденных
|
||||||
|
Тогда Заголовок страницы курса содержит "Python"
|
||||||
|
|
||||||
|
Сценарий: Поиск курсов, стартующих в указанную дату или позже
|
||||||
|
Дано Открыт браузер Chrome
|
||||||
|
Когда Открыт каталог курсов
|
||||||
|
И Найдены курсы со стартом "01.01.2025" или позже
|
||||||
|
Тогда В консоль выведены найденные курсы и даты старта
|
||||||
|
|
||||||
|
Сценарий: Поиск самого дорогого и самого дешевого подготовительного курса
|
||||||
|
Дано Открыт браузер Chrome
|
||||||
|
Когда Открыт раздел Обучение и Подготовительные курсы
|
||||||
|
И Выбраны самый дорогой и самый дешевый курсы с помощью filter
|
||||||
|
Тогда В консоль выведена информация о самом дорогом и самом дешевом курсе
|
||||||
|
|
||||||
|
Сценарий: Поиск самого дорогого и самого дешевого курса в каталоге по полной стоимости со скидкой
|
||||||
|
Дано Открыт браузер Chrome
|
||||||
|
Когда В каталоге курсов выбраны самый дорогой и самый дешевый курсы по полной стоимости со скидкой с помощью filter
|
||||||
|
Тогда В консоль выведена информация о самом дорогом и самом дешевом курсе в каталоге
|
||||||
15
src/test/resources/logback-test.xml
Normal file
15
src/test/resources/logback-test.xml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<configuration>
|
||||||
|
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
|
<encoder>
|
||||||
|
<pattern>%d{HH:mm:ss} %-5level %logger{36} - %msg%n</pattern>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<logger name="io.github.bonigarcia.wdm" level="WARN"/>
|
||||||
|
<logger name="org.openqa.selenium" level="WARN"/>
|
||||||
|
|
||||||
|
<root level="WARN">
|
||||||
|
<appender-ref ref="STDOUT"/>
|
||||||
|
</root>
|
||||||
|
</configuration>
|
||||||
8
src/test/resources/logging.properties
Normal file
8
src/test/resources/logging.properties
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
handlers= java.util.logging.ConsoleHandler
|
||||||
|
.level= WARNING
|
||||||
|
|
||||||
|
java.util.logging.ConsoleHandler.level = SEVERE
|
||||||
|
java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter
|
||||||
|
|
||||||
|
org.openqa.selenium.devtools.level = SEVERE
|
||||||
|
org.openqa.selenium.devtools.CdpVersionFinder.level = SEVERE
|
||||||
Reference in New Issue
Block a user