diff --git a/.gitignore b/.gitignore index d4e22f7..f909020 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ build/ jenkins_home/ compose/.env lib/ +compose/selenoid/video/* +!compose/selenoid/video/.gitkeep +compose/selenoid/logs/* +!compose/selenoid/logs/.gitkeep diff --git a/README.md b/README.md index abafac3..2c2c921 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,23 @@ -# OTUS HW8: Jenkins + Ansible + JJB + Docker +# OTUS ProjectWork: Jenkins + Ansible + JJB + Docker -Полностью автоматизированная раскатка ДЗ8: +Полностью автоматизированная раскатка проектной работы: - Jenkins controller в Docker; - reverse proxy Nginx; - локальный Docker Registry; - Jenkins agents (docker slaves); - Jenkins Job Builder (JJB), без ручного создания job; - Ansible для полного подъема/сноса стенда; -- 6 job (runner + uploader + 3 тестовые + infra check); -- Allure отчеты для UI и mobile. +- Selenoid + Selenoid UI; +- расширенный набор job (web/api/mobile + runner + uploader + infra check); +- Allure отчеты для всех контуров. -Тестовые проекты всегда берутся из Git в runtime: -- `homework_4` (Selenium + API Citrus); -- `homework_7` (Appium, APK скачивается автоматически через `APP_URL`). +Тестовые проекты берутся из Git в runtime: +- `homework_1` (`main`, `homework_2`) - WEB BDD/Cucumber; +- `hw3` - API REST; +- `homework_4` - Selenium + API Citrus; +- `hw3` (`homework_5`) - HW5 как ветка в репозитории `hw3` (job `qa-maven-extra-tests`); +- `homework_6` - Playwright; +- `homework_7` - Appium, APK скачивается автоматически через `APP_URL`; ## Что поднимается @@ -20,20 +25,27 @@ - Jenkins: `http://localhost:8081` - Jenkins через Nginx: `http://localhost:8088` - Registry: `http://localhost:5005/v2/` +- Selenoid: `http://localhost:4444/wd/hub` +- Selenoid UI: `http://localhost:8089` - Agent `maven` (docker label: `maven docker`) - Agent `jjb` (docker label: `jjb docker`) - one-shot `jobs_uploader` container Контроллер не исполняет сборки (`numExecutors: 0`, mode `EXCLUSIVE`), все job идут только на агентах. -## 6 job +## Набор job 1. `jobs-uploader` - накатывает/обновляет job через JJB. 2. `infra-health-check` - проверка Jenkins/Registry/образов/агентов. -3. `qa-selenium-tests` - Selenium/Selenide с выбором браузера. -4. `qa-api-citrus-tests` - API тесты. -5. `qa-mobile-appium-tests` - Appium тесты, APK через `APP_URL`. -6. `qa-runner` - параллельный запуск `selenium + api + mobile`. +3. `qa-runner` - оркестратор параллельного запуска всех тестовых контуров (cron: `15 1 * * *`). +4. `qa-selenium-tests` +5. `qa-web-bdd-tests` +6. `qa-api-citrus-tests` +7. `qa-api-contract-tests` +8. `qa-api-rest-tests` +9. `qa-playwright-tests` +10. `qa-maven-extra-tests` +11. `qa-mobile-appium-tests` Вьюхи в Jenkins: - `DevOps` @@ -59,7 +71,7 @@ Подготовка: ```bash -cd /path/to/hw8 +cd /path/to/projectwork cp compose/.env.example compose/.env ``` @@ -77,10 +89,10 @@ ansible-playbook -i ansible/inventory/hosts.ini ansible/playbooks/site.yml -e jo `wsl`: ```powershell -wsl bash -lc "cd /mnt/c/Users/spawn/IdeaProjects/otus-autotests/hw8 && ansible-playbook -i ansible/inventory/hosts.ini ansible/playbooks/site.yml -e jobs_profile=devops" +wsl bash -lc "cd /mnt/c/Users/spawn/IdeaProjects/otus-autotests/projectwork && ansible-playbook -i ansible/inventory/hosts.ini ansible/playbooks/site.yml -e jobs_profile=devops" ``` -После этого запускаете `jobs-uploader` (дефолт `JJB_PATH=/workspace/hw8/config/jobs`) и получаете QA job. +После этого запускаете `jobs-uploader` (дефолт `JJB_PATH=/workspace/projectwork/config/jobs`) и получаете QA job. ### Вариант 2: сразу все job @@ -91,7 +103,7 @@ ansible-playbook -i ansible/inventory/hosts.ini ansible/playbooks/site.yml `wsl`: ```powershell -wsl bash -lc "cd /mnt/c/Users/spawn/IdeaProjects/otus-autotests/hw8 && ansible-playbook -i ansible/inventory/hosts.ini ansible/playbooks/site.yml" +wsl bash -lc "cd /mnt/c/Users/spawn/IdeaProjects/otus-autotests/projectwork && ansible-playbook -i ansible/inventory/hosts.ini ansible/playbooks/site.yml" ``` Примечание: можно запускать и без `-e jobs_profile=full` — по умолчанию в `site.yml` используется профиль `full` (раскатываются все job). @@ -100,56 +112,67 @@ wsl bash -lc "cd /mnt/c/Users/spawn/IdeaProjects/otus-autotests/hw8 && ansible-p Рекомендуемый вход: `QA-Runner` -> `qa-runner` -> `Build with Parameters`. -`qa-runner` запускает в параллели: -- `qa-selenium-tests` -- `qa-api-citrus-tests` -- `qa-mobile-appium-tests` - -И всегда публикует `runner-summary.txt` с: +`qa-runner` запускает в параллели все контуры и всегда публикует `runner-summary.txt` с: - номерами дочерних сборок; - статусами; - ссылками на каждую дочернюю job. +Для BDD по умолчанию запускаются обе ветки из `homework_1`: `main` и `homework_2` (`BDD_REFS=main,homework_2`). + ## Отчетность -`qa-selenium-tests` и `qa-mobile-appium-tests` публикуют: +Все тестовые job публикуют: - Allure report (`/allure`); - JUnit результаты; - `run-info.txt`; -- `target/**` артефакты (включая скриншоты/логи тестового проекта, если они туда пишутся). -- для mobile дополнительно сохраняются `project/target/mobile-debug/*` (`docker logs` и `docker inspect` эмуляторов/сети). -- для mobile параметр `SUREFIRE_RERUN_FAILING` (по умолчанию `1`) снижает flaky-падения Appium-сессий без правок кода HW7. +- `target/**` артефакты (или аналогичные артефакты проекта). Дополнительно в Allure: - `environment.properties` - `executor.json` -- `categories.json` +- `categories.json` (там, где применимо) -Также в `currentBuild.description` пишется, кто запустил job и ключевые параметры. +Для `qa-selenium-tests` и `qa-web-bdd-tests`: +- включен сбор всех новых mp4 из Selenoid за прогон; +- видео прикладываются в Allure через external attachments. + +Для `qa-playwright-tests`: +- по умолчанию используется `mcr.microsoft.com/playwright/java:v1.58.0-jammy` (`PLAYWRIGHT_DOCKER_IMAGE`). + +Для `qa-mobile-appium-tests`: +- перед запуском синхронизируются `wiremock/mappings` и `wiremock/__files/wishlist.apk` в контейнер; +- проверяется доступность `APP_URL` изнутри эмулятора; +- сохраняются `project/target/mobile-debug/*`. + +`qa-runner` завершает сборку как `UNSTABLE`, если падают только UI-контуры (`qa-selenium-tests`, `qa-web-bdd-tests`, `qa-playwright-tests`). Для остальных контуров падение блокирующее (`FAILURE`). ## Команды для повторного запуска с нуля Снести все: +`bash`: ```bash ansible-playbook -i ansible/inventory/hosts.ini ansible/playbooks/down.yml ``` +`wsl`: ```powershell -wsl bash -lc "cd /mnt/c/Users/spawn/IdeaProjects/otus-autotests/hw8 && ansible-playbook -i ansible/inventory/hosts.ini ansible/playbooks/down.yml" +wsl bash -lc "cd /mnt/c/Users/spawn/IdeaProjects/otus-autotests/projectwork && ansible-playbook -i ansible/inventory/hosts.ini ansible/playbooks/down.yml" ``` Полный reset + deploy: По умолчанию эта команда раскатывает профиль `full` (все job: DevOps + QA), так как в `ansible/playbooks/site.yml` задано `jobs_profile | default('full')`. +`bash`: ```bash ansible-playbook -i ansible/inventory/hosts.ini ansible/playbooks/down.yml ansible-playbook -i ansible/inventory/hosts.ini ansible/playbooks/site.yml ``` +`wsl`: ```powershell -wsl bash -lc "cd /mnt/c/Users/spawn/IdeaProjects/otus-autotests/hw8 && ansible-playbook -i ansible/inventory/hosts.ini ansible/playbooks/down.yml && ansible-playbook -i ansible/inventory/hosts.ini ansible/playbooks/site.yml" +wsl bash -lc "cd /mnt/c/Users/spawn/IdeaProjects/otus-autotests/projectwork && ansible-playbook -i ansible/inventory/hosts.ini ansible/playbooks/down.yml && ansible-playbook -i ansible/inventory/hosts.ini ansible/playbooks/site.yml" ``` Если нужен только профиль `devops`, добавьте `-e jobs_profile=devops` во вторую команду (`site.yml`). @@ -157,7 +180,9 @@ wsl bash -lc "cd /mnt/c/Users/spawn/IdeaProjects/otus-autotests/hw8 && ansible-p ## Где что лежит - `ansible/` - playbook раскатки/удаления -- `compose/` - инфраструктура Jenkins/agents/registry/nginx +- `compose/` - инфраструктура Jenkins/agents/registry/nginx/selenoid - `compose/images/` - Dockerfile для агентов и test-runner образов - `config/jobs/` - full JJB (DevOps + QA) - `config/jobs-devops/` - JJB профиль только для DevOps job +- `config/wiremock/` - маппинги для контрактных API тестов +- `contracts-tests/` - API контрактные тесты (JsonSchemaValidation) diff --git a/ansible/playbooks/down.yml b/ansible/playbooks/down.yml index e302db2..b6c7ac7 100644 --- a/ansible/playbooks/down.yml +++ b/ansible/playbooks/down.yml @@ -1,10 +1,10 @@ --- -- name: Destroy OTUS HW8 Jenkins infrastructure +- name: Destroy OTUS ProjectWork Jenkins infrastructure hosts: local gather_facts: false vars: - hw8_root: "{{ playbook_dir }}/../.." - compose_dir: "{{ hw8_root }}/compose" + project_root: "{{ playbook_dir }}/../.." + compose_dir: "{{ project_root }}/compose" compose_file: "{{ compose_dir }}/docker-compose.yml" compose_env_file: "{{ compose_dir }}/.env" diff --git a/ansible/playbooks/site.yml b/ansible/playbooks/site.yml index 68139bc..8f454d6 100644 --- a/ansible/playbooks/site.yml +++ b/ansible/playbooks/site.yml @@ -1,16 +1,17 @@ --- -- name: Deploy OTUS HW8 Jenkins infrastructure +- name: Deploy OTUS ProjectWork Jenkins infrastructure hosts: local gather_facts: true vars: - hw8_root: "{{ playbook_dir }}/../.." - compose_dir: "{{ hw8_root }}/compose" + project_root: "{{ playbook_dir }}/../.." + compose_dir: "{{ project_root }}/compose" compose_file: "{{ compose_dir }}/docker-compose.yml" compose_env_file: "{{ compose_dir }}/.env" + selenoid_video_output_dir: "{{ compose_dir }}/selenoid/video" jobs_profile_effective: "{{ jobs_profile | default('full') }}" jjb_paths: - full: /workspace/hw8/config/jobs - devops: /workspace/hw8/config/jobs-devops + full: /workspace/projectwork/config/jobs + devops: /workspace/projectwork/config/jobs-devops jjb_upload_path: "{{ jjb_paths.get(jobs_profile_effective, jjb_paths.full) }}" tasks: @@ -42,11 +43,13 @@ delay: 20 until: pull_jenkins_base.rc == 0 - - name: Build and start registry, jenkins and nginx + - name: Build and start core services (registry, jenkins, nginx, selenoid) ansible.builtin.command: - cmd: docker compose -f {{ compose_file }} --env-file {{ compose_env_file }} up -d --build registry jenkins nginx + cmd: docker compose -f {{ compose_file }} --env-file {{ compose_env_file }} up -d --build registry jenkins nginx selenoid selenoid-ui args: chdir: "{{ compose_dir }}" + environment: + SELENOID_VIDEO_OUTPUT_DIR: "{{ selenoid_video_output_dir }}" register: compose_bootstrap retries: 4 delay: 20 @@ -61,6 +64,23 @@ delay: 5 until: jenkins_ready.status == 200 + - name: Wait for Selenoid to become available + ansible.builtin.uri: + url: "http://127.0.0.1:4444/status" + status_code: 200 + register: selenoid_ready + retries: 60 + delay: 5 + until: selenoid_ready.status == 200 + + - name: Pre-pull Selenoid browser and recorder images + ansible.builtin.command: + cmd: docker pull {{ item }} + loop: + - selenoid/vnc:chrome_128.0 + - selenoid/vnc:firefox_125.0 + - selenoid/video-recorder:latest-release + - name: Build and push slave/test images to local registry ansible.builtin.command: cmd: ./scripts/build_and_push_images.sh localhost:5005 1.0.0 @@ -76,12 +96,16 @@ cmd: docker compose -f {{ compose_file }} --env-file {{ compose_env_file }} up -d agent-maven agent-jjb args: chdir: "{{ compose_dir }}" + environment: + SELENOID_VIDEO_OUTPUT_DIR: "{{ selenoid_video_output_dir }}" - name: Build jobs_uploader image with latest changes ansible.builtin.command: cmd: docker compose -f {{ compose_file }} --env-file {{ compose_env_file }} build jobs_uploader args: chdir: "{{ compose_dir }}" + environment: + SELENOID_VIDEO_OUTPUT_DIR: "{{ selenoid_video_output_dir }}" - name: Remove stale jobs_uploader run containers ansible.builtin.shell: | @@ -97,6 +121,8 @@ cmd: timeout 900 docker compose -f {{ compose_file }} --env-file {{ compose_env_file }} run --rm -e JJB_PATH={{ jjb_upload_path }} jobs_uploader args: chdir: "{{ compose_dir }}" + environment: + SELENOID_VIDEO_OUTPUT_DIR: "{{ selenoid_video_output_dir }}" - name: Show endpoint details ansible.builtin.debug: diff --git a/compose/docker-compose.yml b/compose/docker-compose.yml index 2c1f5ca..80ef9d3 100644 --- a/compose/docker-compose.yml +++ b/compose/docker-compose.yml @@ -33,7 +33,8 @@ services: - jenkins_home:/var/jenkins_home - ./jenkins/casc:/var/jenkins_home/casc_configs:ro - /var/run/docker.sock:/var/run/docker.sock - - ..:/workspace/hw8:ro + - ..:/workspace/projectwork:ro + - ../..:/workspace/otus:ro healthcheck: test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/login >/dev/null"] interval: 10s @@ -55,6 +56,60 @@ services: networks: - jenkins_net + selenoid: + image: aerokube/selenoid:1.11.3 + restart: unless-stopped + environment: + DOCKER_API_VERSION: "1.44" + OVERRIDE_VIDEO_OUTPUT_DIR: ${SELENOID_VIDEO_OUTPUT_DIR:-/opt/selenoid/video} + ports: + - "4444:4444" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./selenoid/browsers.json:/etc/selenoid/browsers.json:ro + - ./selenoid/video:/opt/selenoid/video + - ./selenoid/logs:/opt/selenoid/logs + command: + - "-conf" + - "/etc/selenoid/browsers.json" + - "-video-recorder-image" + - "selenoid/video-recorder:latest-release" + - "-container-network" + - "otus_jenkins_net" + - "-limit" + - "4" + - "-timeout" + - "5m" + - "-service-startup-timeout" + - "120s" + - "-session-attempt-timeout" + - "120s" + - "-video-output-dir" + - "/opt/selenoid/video" + - "-log-output-dir" + - "/opt/selenoid/logs" + healthcheck: + test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:4444/status >/dev/null 2>&1"] + interval: 10s + timeout: 5s + retries: 30 + networks: + - jenkins_net + + selenoid-ui: + image: aerokube/selenoid-ui:1.10.11 + restart: unless-stopped + depends_on: + selenoid: + condition: service_healthy + ports: + - "8089:8080" + command: + - "--selenoid-uri" + - "http://selenoid:4444" + networks: + - jenkins_net + jobs_uploader: build: context: ./jobs_uploader @@ -67,9 +122,10 @@ services: JENKINS_HOSTNAME: http://jenkins:8080 JENKINS_USERNAME: ${JENKINS_ADMIN_ID} JENKINS_PASSWORD: ${JENKINS_ADMIN_PASSWORD} - JJB_PATH: /workspace/hw8/config/jobs + JJB_PATH: /workspace/projectwork/config/jobs volumes: - - ..:/workspace/hw8:ro + - ..:/workspace/projectwork:ro + - ../..:/workspace/otus:ro networks: - jenkins_net @@ -87,10 +143,13 @@ services: JENKINS_AGENT_WORKDIR: /home/jenkins/agent JENKINS_WEB_SOCKET: "true" JENKINS_LABELS: maven docker - HW8_ROOT: /workspace/hw8 + OTUS_WORKSPACE_ROOT: /workspace/projectwork volumes: - /var/run/docker.sock:/var/run/docker.sock - - ..:/workspace/hw8:ro + - ..:/workspace/projectwork:ro + - ../..:/workspace/otus:ro + extra_hosts: + - "host.docker.internal:host-gateway" networks: - jenkins_net @@ -108,15 +167,19 @@ services: JENKINS_AGENT_WORKDIR: /home/jenkins/agent JENKINS_WEB_SOCKET: "true" JENKINS_LABELS: jjb docker - HW8_ROOT: /workspace/hw8 + OTUS_WORKSPACE_ROOT: /workspace/projectwork volumes: - /var/run/docker.sock:/var/run/docker.sock - - ..:/workspace/hw8:ro + - ..:/workspace/projectwork:ro + - ../..:/workspace/otus:ro + extra_hosts: + - "host.docker.internal:host-gateway" networks: - jenkins_net networks: jenkins_net: + name: otus_jenkins_net driver: bridge volumes: diff --git a/compose/jenkins/casc/jenkins.yaml b/compose/jenkins/casc/jenkins.yaml index bdaffdf..57c4752 100644 --- a/compose/jenkins/casc/jenkins.yaml +++ b/compose/jenkins/casc/jenkins.yaml @@ -30,7 +30,7 @@ jenkins: - key: JENKINS_URL_INTERNAL value: "http://jenkins:8080" - key: OTUS_WORKSPACE_ROOT - value: "/workspace/hw8" + value: "/workspace/projectwork" - key: MOBILE_DB_PASSWORD value: "${MOBILE_DB_PASSWORD}" diff --git a/compose/selenoid/browsers.json b/compose/selenoid/browsers.json new file mode 100644 index 0000000..c8025ee --- /dev/null +++ b/compose/selenoid/browsers.json @@ -0,0 +1,22 @@ +{ + "chrome": { + "default": "128.0", + "versions": { + "128.0": { + "image": "selenoid/vnc:chrome_128.0", + "port": "4444", + "path": "/" + } + } + }, + "firefox": { + "default": "125.0", + "versions": { + "125.0": { + "image": "selenoid/vnc:firefox_125.0", + "port": "4444", + "path": "/wd/hub" + } + } + } +} diff --git a/compose/selenoid/logs/.gitkeep b/compose/selenoid/logs/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/compose/selenoid/logs/.gitkeep @@ -0,0 +1 @@ + diff --git a/compose/selenoid/video/.gitkeep b/compose/selenoid/video/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/compose/selenoid/video/.gitkeep @@ -0,0 +1 @@ + diff --git a/config/jobs-devops/global.yaml b/config/jobs-devops/global.yaml index ff88d0c..932786a 100644 --- a/config/jobs-devops/global.yaml +++ b/config/jobs-devops/global.yaml @@ -1,6 +1,6 @@ --- - defaults: name: global - project_folder: /workspace/hw8 + project_folder: /workspace/projectwork test_image_tag: "1.0.0" build_keep: 40 diff --git a/config/jobs-devops/templates/jobs-uploader.yaml b/config/jobs-devops/templates/jobs-uploader.yaml index 17a61cb..4a09303 100644 --- a/config/jobs-devops/templates/jobs-uploader.yaml +++ b/config/jobs-devops/templates/jobs-uploader.yaml @@ -11,6 +11,6 @@ parameters: - string: name: JJB_PATH - default: /workspace/hw8/config/jobs + default: /workspace/projectwork/config/jobs description: "Путь до JJB-конфигов" dsl: !include-raw-verbatim: ../../jobs/scripts/jobs-uploader.groovy diff --git a/config/jobs/global.yaml b/config/jobs/global.yaml index ff88d0c..932786a 100644 --- a/config/jobs/global.yaml +++ b/config/jobs/global.yaml @@ -1,6 +1,6 @@ --- - defaults: name: global - project_folder: /workspace/hw8 + project_folder: /workspace/projectwork test_image_tag: "1.0.0" build_keep: 40 diff --git a/config/jobs/macros/common.yaml b/config/jobs/macros/common.yaml index 6ae2084..b19a219 100644 --- a/config/jobs/macros/common.yaml +++ b/config/jobs/macros/common.yaml @@ -1,6 +1,6 @@ --- - property: - name: hw8-build-policy + name: projectwork-build-policy properties: - build-discarder: num-to-keep: 40 diff --git a/config/jobs/scripts/infra-health-check.groovy b/config/jobs/scripts/infra-health-check.groovy index 2868998..48df1e6 100644 --- a/config/jobs/scripts/infra-health-check.groovy +++ b/config/jobs/scripts/infra-health-check.groovy @@ -23,6 +23,9 @@ pipeline { set -eux docker version curl -fsS http://registry:5000/v2/_catalog + curl -fsS http://host.docker.internal:4444/status + docker pull selenoid/vnc:chrome_128.0 + docker pull selenoid/video-recorder:latest-release || docker pull selenoid/video-recorder:latest docker pull localhost:5005/otus/test-selenium:1.0.0 docker pull localhost:5005/otus/test-api:1.0.0 docker pull localhost:5005/otus/test-mobile:1.0.0 @@ -33,8 +36,8 @@ pipeline { steps { sh ''' set -eux - HW8_ROOT_PATH="${HW8_ROOT:-/workspace/hw8}" - test -f "${HW8_ROOT_PATH}/config/jobs/global.yaml" + PROJECT_ROOT_PATH="${OTUS_WORKSPACE_ROOT:-/workspace/projectwork}" + test -f "${PROJECT_ROOT_PATH}/config/jobs/global.yaml" ''' } } diff --git a/config/jobs/scripts/qa-api-citrus-tests.groovy b/config/jobs/scripts/qa-api-citrus-tests.groovy index e02dcfd..93265c8 100644 --- a/config/jobs/scripts/qa-api-citrus-tests.groovy +++ b/config/jobs/scripts/qa-api-citrus-tests.groovy @@ -22,6 +22,7 @@ pipeline { steps { sh ''' set -eux + git config --global --add safe.directory '*' || true rm -rf ./sources git clone "${QA_REPO_URL}" ./sources git -C ./sources checkout "${QA_REPO_REF}" @@ -40,7 +41,7 @@ pipeline { echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" } > ./artifacts/run-info.txt - CID="$(docker create localhost:5005/otus/test-api:1.0.0 bash -lc "set -e; cd /workspace; mvn -f citrus-tests/pom.xml test")" + CID="$(docker create localhost:5005/otus/test-api:1.0.0 bash -lc "set -e; cd /workspace; mvn -f citrus-tests/pom.xml -Dallure.results.directory=target/allure-results test")" cleanup_container() { docker rm -f "${CID}" >/dev/null 2>&1 || true } @@ -50,6 +51,53 @@ pipeline { docker start -a "${CID}" TEST_RC=$? docker cp "${CID}:/workspace/citrus-tests/target" "./artifacts/citrus-target" || true + mkdir -p ./artifacts/citrus-target/allure-results + if ! ls ./artifacts/citrus-target/allure-results/* >/dev/null 2>&1; then + EXTRA_UUID="$(cat /proc/sys/kernel/random/uuid)" + TS_MS="$(( $(date +%s) * 1000 ))" + STATUS="passed" + if [ "${TEST_RC}" -ne 0 ]; then + STATUS="failed" + fi + cat > "./artifacts/citrus-target/allure-results/${EXTRA_UUID}-result.json" < ./artifacts/citrus-target/allure-results/environment.properties + cat > ./artifacts/citrus-target/allure-results/executor.json </dev/null + + cleanup_all() { + docker rm -f "${WM_CID}" >/dev/null 2>&1 || true + docker rm -f "${CID:-}" >/dev/null 2>&1 || true + } + trap cleanup_all EXIT INT TERM + + for _ in $(seq 1 30); do + if curl -fsS "http://${WM_IP}:8080/__admin/health" >/dev/null; then + break + fi + sleep 2 + done + curl -fsS "http://${WM_IP}:8080/__admin/health" >/dev/null + + CID="$(docker create \ + --add-host=host.docker.internal:host-gateway \ + localhost:5005/otus/test-api:1.0.0 \ + bash -lc "set -e; cd /workspace; mvn -f contracts-tests/pom.xml -Dallure.results.directory=target/allure-results -DbaseUrl=http://${WM_IP}:8080 test")" + tar -C "${PROJECT_ROOT_PATH}" -cf - contracts-tests | docker cp - "${CID}:/workspace" + set +e + docker start -a "${CID}" + TEST_RC=$? + docker cp "${CID}:/workspace/contracts-tests/target" "./artifacts" || true + mkdir -p ./artifacts/target/allure-results + if ! ls ./artifacts/target/allure-results/* >/dev/null 2>&1; then + EXTRA_UUID="$(cat /proc/sys/kernel/random/uuid)" + TS_MS="$(( $(date +%s) * 1000 ))" + STATUS="passed" + if [ "${TEST_RC}" -ne 0 ]; then + STATUS="failed" + fi + cat > "./artifacts/target/allure-results/${EXTRA_UUID}-result.json" < ./artifacts/target/allure-results/environment.properties + cat > ./artifacts/target/allure-results/executor.json </dev/null 2>&1 || true + } + trap cleanup_container EXIT INT TERM + tar -C ./sources -cf - . | docker cp - "${CID}:/workspace" + set +e + docker start -a "${CID}" + TEST_RC=$? + docker cp "${CID}:/workspace/target" "./artifacts" || true + mkdir -p ./artifacts/target/allure-results + if ! ls ./artifacts/target/allure-results/* >/dev/null 2>&1; then + EXTRA_UUID="$(cat /proc/sys/kernel/random/uuid)" + TS_MS="$(( $(date +%s) * 1000 ))" + STATUS="passed" + if [ "${TEST_RC}" -ne 0 ]; then + STATUS="failed" + fi + cat > "./artifacts/target/allure-results/${EXTRA_UUID}-result.json" < ./artifacts/run-info.txt + { + echo "job=${JOB_NAME}" + echo "build=${BUILD_NUMBER}" + echo "repo.url=${API_REST_REPO_URL}" + echo "repo.ref=${TARGET_REF}" + echo "repo.sha=${GIT_SHA}" + echo "maven.goal=${API_REST_MAVEN_GOAL}" + } > ./artifacts/target/allure-results/environment.properties + cat > ./artifacts/target/allure-results/executor.json < "./artifacts/target/allure-results/${EXTRA_UUID}-result.json" < ./artifacts/target/allure-results/environment.properties + cat > ./artifacts/target/allure-results/executor.json < ./artifacts/run-info.txt + exit 0 + fi + + git clone "${EXTRA_REPO_URL}" ./sources + git -C ./sources checkout "${EXTRA_REPO_REF}" + GIT_SHA="$(git -C ./sources rev-parse --short HEAD)" + + CID="$(docker create localhost:5005/otus/test-api:1.0.0 bash -lc "set -e; cd /workspace; mvn -Dallure.results.directory=target/allure-results ${EXTRA_MAVEN_GOAL}")" + cleanup_container() { + docker rm -f "${CID}" >/dev/null 2>&1 || true + } + trap cleanup_container EXIT INT TERM + tar -C ./sources -cf - . | docker cp - "${CID}:/workspace" + set +e + docker start -a "${CID}" + TEST_RC=$? + docker cp "${CID}:/workspace/target" "./artifacts" || true + mkdir -p ./artifacts/target/allure-results + if ! ls ./artifacts/target/allure-results/* >/dev/null 2>&1; then + EXTRA_UUID="$(cat /proc/sys/kernel/random/uuid)" + TS_MS="$(( $(date +%s) * 1000 ))" + STATUS="passed" + if [ "${TEST_RC}" -ne 0 ]; then + STATUS="failed" + fi + cat > "./artifacts/target/allure-results/${EXTRA_UUID}-result.json" < ./artifacts/run-info.txt + { + echo "job=${JOB_NAME}" + echo "build=${BUILD_NUMBER}" + echo "repo.url=${EXTRA_REPO_URL}" + echo "repo.ref=${EXTRA_REPO_REF}" + echo "repo.sha=${GIT_SHA}" + echo "maven.goal=${EXTRA_MAVEN_GOAL}" + } > ./artifacts/target/allure-results/environment.properties + cat > ./artifacts/target/allure-results/executor.json </dev/null 2>&1 || true + } + trap cleanup_container EXIT INT TERM + tar -C ./sources -cf - . | docker cp - "${CID}:/tmp" + set +e + docker start -a "${CID}" + TEST_RC=$? + docker cp "${CID}:/tmp/target" "./artifacts/target" || true + docker cp "${CID}:/tmp/traces" "./artifacts/traces" || true + mkdir -p ./artifacts/target/allure-results + ATTACHMENT_COUNT=0 + ATTACHMENTS_MANIFEST="./artifacts/target/allure-results/.external-attachments.txt" + : > "${ATTACHMENTS_MANIFEST}" + ATTACH_FILES="$(find ./artifacts -type f \\( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.mp4' -o -iname '*.webm' -o -iname '*.zip' \\) 2>/dev/null || true)" + if [ -n "${ATTACH_FILES}" ]; then + while IFS= read -r attachment_file; do + if [ -z "${attachment_file}" ] || [ ! -f "${attachment_file}" ]; then + continue + fi + ATTACHMENT_COUNT=$((ATTACHMENT_COUNT + 1)) + ext="$(echo "${attachment_file}" | awk -F. '{print tolower($NF)}')" + mime="application/octet-stream" + case "${ext}" in + png) mime="image/png" ;; + jpg|jpeg) mime="image/jpeg" ;; + mp4) mime="video/mp4" ;; + webm) mime="video/webm" ;; + zip) mime="application/zip" ;; + esac + source_name="external-attachment-${ATTACHMENT_COUNT}.${ext}" + cp "${attachment_file}" "./artifacts/target/allure-results/${source_name}" || continue + safe_name="$(basename "${attachment_file}" | sed 's/"/\\"/g')" + printf '%s|%s|%s\n' "${safe_name}" "${mime}" "${source_name}" >> "${ATTACHMENTS_MANIFEST}" + done < "./artifacts/target/allure-results/${EXTRA_UUID}-result.json" < ./artifacts/run-info.txt + { + echo "job=${JOB_NAME}" + echo "build=${BUILD_NUMBER}" + echo "repo.url=${PLAYWRIGHT_REPO_URL}" + echo "repo.ref=${TARGET_REF}" + echo "repo.sha=${GIT_SHA}" + echo "browser=${PLAYWRIGHT_BROWSER}" + echo "headless=${PLAYWRIGHT_HEADLESS}" + echo "base.url=${PLAYWRIGHT_BASE_URL}" + echo "docker.image=${TEST_IMAGE}" + } > ./artifacts/target/allure-results/environment.properties + cat > ./artifacts/target/allure-results/executor.json </dev/null 2>&1; then compose_cmd() { PROJECT_DIR="$PWD" docker compose -f "${COMPOSE_FILE}" "$@"; } elif docker-compose version >/dev/null 2>&1; then @@ -96,6 +97,42 @@ pipeline { compose_cmd down -v --remove-orphans || true compose_cmd up -d ${SERVICES} + WIREMOCK_CID="$(compose_cmd ps -q wiremock)" + if [ -z "${WIREMOCK_CID}" ]; then + echo "Wiremock container is not found" + exit 1 + fi + if [ ! -f "./wiremock/mappings/wishlist-apk.json" ] || [ ! -f "./wiremock/__files/wishlist.apk" ]; then + echo "Missing wiremock APK assets in repository checkout: ./wiremock/mappings/wishlist-apk.json or ./wiremock/__files/wishlist.apk" + exit 1 + fi + docker exec "${WIREMOCK_CID}" sh -lc 'rm -rf /home/wiremock/mappings /home/wiremock/__files && mkdir -p /home/wiremock/mappings /home/wiremock/__files' + tar -C ./wiremock -cf - . | docker exec -i "${WIREMOCK_CID}" sh -lc 'tar -C /home/wiremock -xf -' + docker restart "${WIREMOCK_CID}" + for i in $(seq 1 30); do + status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}starting{{end}}' "${WIREMOCK_CID}" || true)" + if [ "${status}" = "healthy" ]; then + break + fi + if [ "${i}" -eq 30 ]; then + echo "Wiremock is not healthy after reload" + docker logs "${WIREMOCK_CID}" || true + exit 1 + fi + sleep 2 + done + if echo "${APP_URL}" | grep -Eq 'https?://wiremock:8080'; then + APK_PATH="$(echo "${APP_URL}" | sed -E 's#https?://[^/]+##')" + if [ -z "${APK_PATH}" ]; then + APK_PATH="/wishlist.apk" + fi + if ! docker exec "${WIREMOCK_CID}" sh -lc "wget -qO- \"http://127.0.0.1:8080${APK_PATH}\" >/dev/null"; then + echo "Wiremock can't serve APK path ${APK_PATH}" + docker exec "${WIREMOCK_CID}" sh -lc 'ls -la /home/wiremock /home/wiremock/mappings /home/wiremock/__files' || true + docker logs "${WIREMOCK_CID}" || true + exit 1 + fi + fi EMULATORS="" SELECTED=0 for service in android-emulator-1 android-emulator-2; do @@ -205,6 +242,39 @@ pipeline { done exit 1 fi + if echo "${APP_URL}" | grep -Eq 'https?://wiremock:8080'; then + APK_PATH="$(echo "${APP_URL}" | sed -E 's#https?://[^/]+##')" + if [ -z "${APK_PATH}" ]; then + APK_PATH="/wishlist.apk" + fi + OLD_IFS="${IFS}" + IFS=',' + for target in ${MOBILE_EMULATORS_VALUE}; do + service_name="$(echo "${target}" | cut -d'|' -f1)" + if [ -z "${service_name}" ]; then + continue + fi + emu_cid="$(docker ps -q --filter "name=mobileci-${service_name}" | head -n1 || true)" + if [ -z "${emu_cid}" ]; then + echo "Emulator container not found for service ${service_name}" + exit 1 + fi + ready="false" + for i in $(seq 1 30); do + if docker exec "${emu_cid}" sh -lc "wget -qO- \"http://wiremock:8080${APK_PATH}\" >/dev/null"; then + ready="true" + break + fi + sleep 2 + done + if [ "${ready}" != "true" ]; then + echo "APK URL is not reachable from ${service_name}: ${APP_URL}" + docker logs --tail 200 "${emu_cid}" || true + exit 1 + fi + done + IFS="${OLD_IFS}" + fi run_single_class() { class_name="$1" @@ -410,8 +480,8 @@ EOF dir('project') { sh ''' set +e - HW8_ROOT_PATH="${HW8_ROOT:-/workspace/hw8}" - COMPOSE_FILE="${HW8_ROOT_PATH}/config/compose/mobile-ci.compose.yml" + PROJECT_ROOT_PATH="${OTUS_WORKSPACE_ROOT:-/workspace/projectwork}" + COMPOSE_FILE="${PROJECT_ROOT_PATH}/config/compose/mobile-ci.compose.yml" if docker compose version >/dev/null 2>&1; then compose_cmd() { PROJECT_DIR="$PWD" docker compose -f "${COMPOSE_FILE}" "$@"; } elif docker-compose version >/dev/null 2>&1; then diff --git a/config/jobs/scripts/qa-runner.groovy b/config/jobs/scripts/qa-runner.groovy index 2273307..dd02333 100644 --- a/config/jobs/scripts/qa-runner.groovy +++ b/config/jobs/scripts/qa-runner.groovy @@ -8,12 +8,24 @@ pipeline { stage('Prepare Metadata') { steps { script { + def bddRefsRaw = (params.BDD_REFS ?: '').trim() + def bddRefs = bddRefsRaw + ? bddRefsRaw.split(',').collect { it.trim() }.findAll { it }.unique() + : [] + if (bddRefs.isEmpty() && (params.BDD_REPO_REF ?: '').trim()) { + bddRefs = [params.BDD_REPO_REF.trim()] + } + if (bddRefs.isEmpty()) { + bddRefs = ['main', 'homework_2'] + } + env.BDD_REFS_EFFECTIVE = bddRefs.join(',') + wrap([$class: 'BuildUser']) { env.RUN_TRIGGER_USER = env.BUILD_USER_ID ?: env.BUILD_USER ?: 'system' env.RUN_TRIGGER_NAME = env.BUILD_USER ?: env.BUILD_USER_ID ?: 'system' } currentBuild.displayName = "#${env.BUILD_NUMBER} runner" - currentBuild.description = "by=${env.RUN_TRIGGER_NAME}; qaRef=${params.QA_REPO_REF}; mobileRef=${params.MOBILE_REPO_REF}" + currentBuild.description = "by=${env.RUN_TRIGGER_NAME}; bddRefs=${env.BDD_REFS_EFFECTIVE}; qaRef=${params.QA_REPO_REF}; mobileRef=${params.MOBILE_REPO_REF}" } } } @@ -21,6 +33,33 @@ pipeline { steps { script { def fanout = [:] + def bddRefs = env.BDD_REFS_EFFECTIVE + .split(',') + .collect { it.trim() } + .findAll { it } + if (bddRefs.isEmpty()) { + bddRefs = ['main', 'homework_2'] + } + + bddRefs.each { ref -> + def refValue = ref + def refKey = ref.replaceAll(/[^A-Za-z0-9_.-]/, '_') + def runFile = "downstream-web-bdd-${refKey}.txt" + fanout["web-bdd-${refKey}"] = { + def run = build job: 'qa-web-bdd-tests', + wait: true, + propagate: false, + parameters: [ + string(name: 'BDD_REPO_URL', value: params.BDD_REPO_URL), + string(name: 'BDD_REPO_REF', value: refValue), + string(name: 'BROWSER', value: params.BROWSER), + string(name: 'BASE_URL', value: params.BASE_URL), + string(name: 'SELENOID_URL', value: params.SELENOID_URL), + string(name: 'HEADLESS', value: params.HEADLESS) + ] + writeFile file: runFile, text: "${run.number}|${run.result}\n" + } + } fanout['selenium'] = { def run = build job: 'qa-selenium-tests', @@ -73,6 +112,50 @@ pipeline { writeFile file: 'downstream-api.txt', text: "${run.number}|${run.result}\n" } + fanout['api-contract'] = { + def run = build job: 'qa-api-contract-tests', + wait: true, + propagate: false + writeFile file: 'downstream-api-contract.txt', text: "${run.number}|${run.result}\n" + } + + fanout['api-rest'] = { + def run = build job: 'qa-api-rest-tests', + wait: true, + propagate: false, + parameters: [ + string(name: 'API_REST_REPO_URL', value: params.API_REST_REPO_URL), + string(name: 'API_REST_REPO_REF', value: params.API_REST_REPO_REF) + ] + writeFile file: 'downstream-api-rest.txt', text: "${run.number}|${run.result}\n" + } + + fanout['extra'] = { + def run = build job: 'qa-maven-extra-tests', + wait: true, + propagate: false, + parameters: [ + string(name: 'EXTRA_REPO_URL', value: params.EXTRA_REPO_URL), + string(name: 'EXTRA_REPO_REF', value: params.EXTRA_REPO_REF) + ] + writeFile file: 'downstream-extra.txt', text: "${run.number}|${run.result}\n" + } + + fanout['playwright'] = { + def run = build job: 'qa-playwright-tests', + wait: true, + propagate: false, + parameters: [ + string(name: 'PLAYWRIGHT_REPO_URL', value: params.PLAYWRIGHT_REPO_URL), + string(name: 'PLAYWRIGHT_REPO_REF', value: params.PLAYWRIGHT_REPO_REF), + string(name: 'PLAYWRIGHT_BROWSER', value: params.PLAYWRIGHT_BROWSER), + string(name: 'PLAYWRIGHT_HEADLESS', value: params.PLAYWRIGHT_HEADLESS), + string(name: 'PLAYWRIGHT_BASE_URL', value: params.BASE_URL), + string(name: 'PLAYWRIGHT_DOCKER_IMAGE', value: params.PLAYWRIGHT_DOCKER_IMAGE) + ] + writeFile file: 'downstream-playwright.txt', text: "${run.number}|${run.result}\n" + } + parallel fanout def parseRun = { String fileName -> @@ -86,36 +169,97 @@ pipeline { ] } + def bddRuns = [:] + bddRefs.each { ref -> + def refKey = ref.replaceAll(/[^A-Za-z0-9_.-]/, '_') + bddRuns[ref] = parseRun("downstream-web-bdd-${refKey}.txt") + } def seleniumRun = parseRun('downstream-selenium.txt') def mobileRun = parseRun('downstream-mobile.txt') def apiRun = parseRun('downstream-api.txt') + def apiContractRun = parseRun('downstream-api-contract.txt') + def apiRestRun = parseRun('downstream-api-rest.txt') + def extraRun = parseRun('downstream-extra.txt') + def playwrightRun = parseRun('downstream-playwright.txt') def lines = [] lines << "job=${env.JOB_NAME}" lines << "build=${env.BUILD_NUMBER}" lines << "trigger_user=${env.RUN_TRIGGER_USER}" lines << "trigger_name=${env.RUN_TRIGGER_NAME}" + lines << "bdd_repo_url=${params.BDD_REPO_URL}" + lines << "bdd_refs=${bddRefs.join(',')}" lines << "qa_repo_url=${params.QA_REPO_URL}" lines << "qa_repo_ref=${params.QA_REPO_REF}" lines << "mobile_repo_url=${params.MOBILE_REPO_URL}" lines << "mobile_repo_ref=${params.MOBILE_REPO_REF}" + lines << "api_rest_repo_url=${params.API_REST_REPO_URL}" + lines << "api_rest_repo_ref=${params.API_REST_REPO_REF}" + lines << "extra_repo_url=${params.EXTRA_REPO_URL}" + lines << "extra_repo_ref=${params.EXTRA_REPO_REF}" + lines << "playwright_repo_url=${params.PLAYWRIGHT_REPO_URL}" + lines << "playwright_repo_ref=${params.PLAYWRIGHT_REPO_REF}" + lines << "playwright_docker_image=${params.PLAYWRIGHT_DOCKER_IMAGE}" + bddRefs.each { ref -> + def refKey = ref.replaceAll(/[^A-Za-z0-9_.-]/, '_') + def run = bddRuns[ref] + lines << "web_bdd_${refKey}_build=${run.number}" + lines << "web_bdd_${refKey}_result=${run.result}" + lines << "web_bdd_${refKey}_link=/job/qa-web-bdd-tests/${run.number}/" + lines << "web_bdd_${refKey}_link_abs=${env.JENKINS_URL}job/qa-web-bdd-tests/${run.number}/" + } + + def primaryBddRef = bddRefs[0] + def primaryBddRun = bddRuns[primaryBddRef] + lines << "web_bdd_build=${primaryBddRun.number}" + lines << "web_bdd_result=${primaryBddRun.result}" lines << "selenium_build=${seleniumRun.number}" lines << "selenium_result=${seleniumRun.result}" lines << "mobile_build=${mobileRun.number}" lines << "mobile_result=${mobileRun.result}" lines << "api_build=${apiRun.number}" lines << "api_result=${apiRun.result}" + lines << "api_contract_build=${apiContractRun.number}" + lines << "api_contract_result=${apiContractRun.result}" + lines << "api_rest_build=${apiRestRun.number}" + lines << "api_rest_result=${apiRestRun.result}" + lines << "extra_build=${extraRun.number}" + lines << "extra_result=${extraRun.result}" + lines << "playwright_build=${playwrightRun.number}" + lines << "playwright_result=${playwrightRun.result}" + lines << "web_bdd_link=/job/qa-web-bdd-tests/${primaryBddRun.number}/" lines << "selenium_link=/job/qa-selenium-tests/${seleniumRun.number}/" lines << "mobile_link=/job/qa-mobile-appium-tests/${mobileRun.number}/" lines << "api_link=/job/qa-api-citrus-tests/${apiRun.number}/" + lines << "api_contract_link=/job/qa-api-contract-tests/${apiContractRun.number}/" + lines << "api_rest_link=/job/qa-api-rest-tests/${apiRestRun.number}/" + lines << "extra_link=/job/qa-maven-extra-tests/${extraRun.number}/" + lines << "playwright_link=/job/qa-playwright-tests/${playwrightRun.number}/" + lines << "web_bdd_link_abs=${env.JENKINS_URL}job/qa-web-bdd-tests/${primaryBddRun.number}/" lines << "selenium_link_abs=${env.JENKINS_URL}job/qa-selenium-tests/${seleniumRun.number}/" lines << "mobile_link_abs=${env.JENKINS_URL}job/qa-mobile-appium-tests/${mobileRun.number}/" lines << "api_link_abs=${env.JENKINS_URL}job/qa-api-citrus-tests/${apiRun.number}/" + lines << "api_contract_link_abs=${env.JENKINS_URL}job/qa-api-contract-tests/${apiContractRun.number}/" + lines << "api_rest_link_abs=${env.JENKINS_URL}job/qa-api-rest-tests/${apiRestRun.number}/" + lines << "extra_link_abs=${env.JENKINS_URL}job/qa-maven-extra-tests/${extraRun.number}/" + lines << "playwright_link_abs=${env.JENKINS_URL}job/qa-playwright-tests/${playwrightRun.number}/" writeFile file: 'runner-summary.txt', text: lines.join('\n') + '\n' archiveArtifacts allowEmptyArchive: false, artifacts: 'runner-summary.txt' - def failed = [seleniumRun, mobileRun, apiRun].any { it.result != 'SUCCESS' } - if (failed) { - error("One or more downstream QA jobs failed. See runner-summary.txt for build links and statuses.") + def criticalRuns = [mobileRun, apiRun, apiContractRun, apiRestRun, extraRun] + def optionalRuns = [] + optionalRuns << seleniumRun + optionalRuns.addAll(bddRuns.values()) + optionalRuns << playwrightRun + + def criticalFailed = criticalRuns.any { it.result != 'SUCCESS' } + def optionalFailed = optionalRuns.any { it.result != 'SUCCESS' } + + if (criticalFailed) { + error("One or more critical downstream QA jobs failed. See runner-summary.txt for build links and statuses.") + } + if (optionalFailed) { + currentBuild.result = 'UNSTABLE' + echo "Optional downstream jobs have failures (web-bdd refs/playwright). See runner-summary.txt for details." } } } diff --git a/config/jobs/scripts/qa-selenium-tests.groovy b/config/jobs/scripts/qa-selenium-tests.groovy index 13619fc..1bfb74b 100644 --- a/config/jobs/scripts/qa-selenium-tests.groovy +++ b/config/jobs/scripts/qa-selenium-tests.groovy @@ -22,10 +22,15 @@ pipeline { steps { sh ''' set -eux + git config --global --add safe.directory '*' || true rm -rf ./sources git clone "${QA_REPO_URL}" ./sources git -C ./sources checkout "${QA_REPO_REF}" GIT_SHA="$(git -C ./sources rev-parse --short HEAD)" + REMOTE_FACTORY="./sources/src/main/java/ru/kovbasa/driver/RemoteDriverFactory.java" + if [ -f "${REMOTE_FACTORY}" ] && ! grep -q "selenoid.video.enabled" "${REMOTE_FACTORY}"; then + sed -i 's/"enableVideo", false/"enableVideo", Boolean.parseBoolean(System.getProperty("selenoid.video.enabled", "false"))/' "${REMOTE_FACTORY}" + fi if ! grep -q "allure-junit5" ./sources/pom.xml; then awk -v ver="${ALLURE_ADAPTER_VERSION}" ' index($0, "") && !done { @@ -64,11 +69,34 @@ pipeline { if [ "${BROWSER}" = "chrome" ]; then EXTRA_ARGS="-Dchrome.binary=/usr/bin/chromium" fi + VIDEO_ARGS="" + SELENOID_BASE="" + SELENOID_VIDEO_BEFORE="./artifacts/selenoid-videos-before.txt" + SELENOID_VIDEO_AFTER="./artifacts/selenoid-videos-after.txt" + SELENOID_VIDEO_NEW="./artifacts/selenoid-videos-new.txt" + : > "${SELENOID_VIDEO_BEFORE}" + : > "${SELENOID_VIDEO_AFTER}" + : > "${SELENOID_VIDEO_NEW}" + if [ "${EXECUTION_MODE}" = "selenoid" ]; then + case "${BROWSER}" in + chrome) docker pull selenoid/vnc:chrome_128.0 || true ;; + firefox) docker pull selenoid/vnc:firefox_125.0 || true ;; + esac + docker pull selenoid/video-recorder:latest-release || docker pull selenoid/video-recorder:latest || true + VIDEO_ARGS="-Dselenoid.video.enabled=true" + if [ -n "${SELENOID_URL}" ]; then + SELENOID_BASE="${SELENOID_URL%/wd/hub}" + (curl -fsS "${SELENOID_BASE}/video/" || true) \ + | tr '"' '\n' \ + | grep -E '\\.mp4$' \ + | sort -u > "${SELENOID_VIDEO_BEFORE}" || true + fi + fi CID="$(docker create \ --add-host=host.docker.internal:host-gateway \ localhost:5005/otus/test-selenium:1.0.0 \ - bash -lc "set -e; cd /workspace; mvn -Dallure.version=${ALLURE_ADAPTER_VERSION} -Dexecution.mode=${EXECUTION_MODE} -Dbrowser=${BROWSER} -Dbrowser.version= -Dselenoid.url=${SELENOID_URL} -Dbase.url=${BASE_URL} -Dselenide.headless=${HEADLESS} -Dallure.results.directory=target/allure-results ${EXTRA_ARGS} test")" + bash -lc "set -e; cd /workspace; mvn -Dallure.version=${ALLURE_ADAPTER_VERSION} -Dexecution.mode=${EXECUTION_MODE} -Dbrowser=${BROWSER} -Dbrowser.version= -Dselenoid.url=${SELENOID_URL} -Dbase.url=${BASE_URL} -Dselenide.headless=${HEADLESS} -Dallure.results.directory=target/allure-results ${VIDEO_ARGS} ${EXTRA_ARGS} test")" cleanup_container() { docker rm -f "${CID}" >/dev/null 2>&1 || true } @@ -79,6 +107,83 @@ pipeline { TEST_RC=$? docker cp "${CID}:/workspace/target" "./artifacts" || true mkdir -p ./artifacts/target/allure-results + if [ "${EXECUTION_MODE}" = "selenoid" ] && [ -n "${SELENOID_URL}" ]; then + (curl -fsS "${SELENOID_BASE}/video/" || true) \ + | tr '"' '\n' \ + | grep -E '\\.mp4$' \ + | sort -u > "${SELENOID_VIDEO_AFTER}" || true + comm -13 "${SELENOID_VIDEO_BEFORE}" "${SELENOID_VIDEO_AFTER}" > "${SELENOID_VIDEO_NEW}" || true + if [ ! -s "${SELENOID_VIDEO_NEW}" ]; then + cp "${SELENOID_VIDEO_AFTER}" "${SELENOID_VIDEO_NEW}" || true + fi + while IFS= read -r video_file; do + [ -n "${video_file}" ] || continue + downloaded=0 + for i in $(seq 1 20); do + if curl -fsS "${SELENOID_BASE}/video/${video_file}" -o "./artifacts/target/${video_file}"; then + downloaded=1 + break + fi + sleep 2 + done + if [ "${downloaded}" -ne 1 ]; then + echo "Failed to fetch Selenoid video: ${video_file}" + fi + done < "${SELENOID_VIDEO_NEW}" + fi + + ATTACHMENT_COUNT=0 + ATTACHMENTS_MANIFEST="./artifacts/target/allure-results/.external-attachments.txt" + : > "${ATTACHMENTS_MANIFEST}" + ATTACH_FILES="$(find ./artifacts -type f \\( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.mp4' -o -iname '*.webm' \\) 2>/dev/null || true)" + if [ -n "${ATTACH_FILES}" ]; then + while IFS= read -r attachment_file; do + if [ -z "${attachment_file}" ] || [ ! -f "${attachment_file}" ]; then + continue + fi + ATTACHMENT_COUNT=$((ATTACHMENT_COUNT + 1)) + ext="$(echo "${attachment_file}" | awk -F. '{print tolower($NF)}')" + mime="application/octet-stream" + case "${ext}" in + png) mime="image/png" ;; + jpg|jpeg) mime="image/jpeg" ;; + mp4) mime="video/mp4" ;; + webm) mime="video/webm" ;; + esac + source_name="external-attachment-${ATTACHMENT_COUNT}.${ext}" + cp "${attachment_file}" "./artifacts/target/allure-results/${source_name}" || continue + safe_name="$(basename "${attachment_file}" | sed 's/"/\\"/g')" + printf '%s|%s|%s\n' "${safe_name}" "${mime}" "${source_name}" >> "${ATTACHMENTS_MANIFEST}" + done < "./artifacts/target/allure-results/${EXTRA_UUID}-result.json" < ./artifacts/target/allure-results/environment.properties cat > ./artifacts/target/allure-results/executor.json < "${CHROME_FACTORY}" <<'EOF' +package ru.kovbasa.driver; + +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.chrome.ChromeDriver; +import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.remote.RemoteWebDriver; + +import java.net.URI; +import java.net.URL; +import java.util.List; +import java.util.Map; + +public class ChromeDriverFactory implements DriverFactory { + + @Override + public WebDriver createDriver() { + final ChromeOptions options = new ChromeOptions(); + options.addArguments("--start-maximized"); + options.addArguments("--disable-notifications"); + final boolean headless = Boolean.parseBoolean(System.getProperty("selenide.headless", "false")); + if (headless) { + options.addArguments("--headless=new"); + } + options.addArguments("--no-sandbox"); + options.addArguments("--disable-dev-shm-usage"); + options.addArguments("--disable-gpu"); + options.addArguments("--window-size=1920,1080"); + options.addArguments("--remote-allow-origins=*"); + + final String remoteUrl = System.getProperty("selenoid.url", "").trim(); + if (!remoteUrl.isEmpty()) { + try { + options.setCapability("selenoid:options", Map.of( + "name", "bdd-ui-tests", + "enableVNC", true, + "enableVideo", Boolean.parseBoolean(System.getProperty("selenoid.video.enabled", "false")), + "env", List.of("TZ=UTC") + )); + final URL url = URI.create(remoteUrl).toURL(); + return new RemoteWebDriver(url, options); + } catch (Exception ex) { + throw new RuntimeException("Failed to create RemoteWebDriver for selenoid.url=" + remoteUrl, ex); + } + } + return new ChromeDriver(options); + } +} +EOF + fi + + rm -rf ./artifacts + mkdir -p ./artifacts + { + echo "job=${JOB_NAME}" + echo "build=${BUILD_NUMBER}" + echo "trigger_user=${RUN_TRIGGER_USER}" + echo "trigger_name=${RUN_TRIGGER_NAME}" + echo "bdd_repo_url=${BDD_REPO_URL}" + echo "bdd_repo_ref=${TARGET_REF}" + echo "bdd_repo_sha=${GIT_SHA}" + echo "browser=${BROWSER}" + echo "headless=${HEADLESS}" + echo "base_url=${BASE_URL}" + echo "selenoid_url=${SELENOID_URL}" + echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } > ./artifacts/run-info.txt + + EXTRA_ARGS="" + if [ "${BROWSER}" = "chrome" ]; then + EXTRA_ARGS="-Dchrome.binary=/usr/bin/chromium" + fi + SELENOID_ARGS="" + SELENOID_BASE="" + SELENOID_VIDEO_BEFORE="./artifacts/selenoid-videos-before.txt" + SELENOID_VIDEO_AFTER="./artifacts/selenoid-videos-after.txt" + SELENOID_VIDEO_NEW="./artifacts/selenoid-videos-new.txt" + : > "${SELENOID_VIDEO_BEFORE}" + : > "${SELENOID_VIDEO_AFTER}" + : > "${SELENOID_VIDEO_NEW}" + if [ -n "${SELENOID_URL}" ]; then + case "${BROWSER}" in + chrome) docker pull selenoid/vnc:chrome_128.0 || true ;; + firefox) docker pull selenoid/vnc:firefox_125.0 || true ;; + esac + docker pull selenoid/video-recorder:latest-release || docker pull selenoid/video-recorder:latest || true + SELENOID_ARGS="-Dselenoid.video.enabled=true" + SELENOID_BASE="${SELENOID_URL%/wd/hub}" + (curl -fsS "${SELENOID_BASE}/video/" || true) \ + | tr '"' '\n' \ + | grep -E '\\.mp4$' \ + | sort -u > "${SELENOID_VIDEO_BEFORE}" || true + fi + + CID="$(docker create \ + --add-host=host.docker.internal:host-gateway \ + localhost:5005/otus/test-selenium:1.0.0 \ + bash -lc "set -e; cd /workspace; mvn -Dallure.version=${ALLURE_ADAPTER_VERSION} -Dbrowser=${BROWSER} -Dbase.url=${BASE_URL} -Dselenoid.url=${SELENOID_URL} -Dselenide.headless=${HEADLESS} -Dallure.results.directory=target/allure-results ${SELENOID_ARGS} ${EXTRA_ARGS} test")" + cleanup_container() { + docker rm -f "${CID}" >/dev/null 2>&1 || true + } + trap cleanup_container EXIT INT TERM + tar -C ./sources -cf - . | docker cp - "${CID}:/workspace" + set +e + docker start -a "${CID}" + TEST_RC=$? + docker cp "${CID}:/workspace/target" "./artifacts" || true + mkdir -p ./artifacts/target/allure-results + if [ -n "${SELENOID_URL}" ]; then + (curl -fsS "${SELENOID_BASE}/video/" || true) \ + | tr '"' '\n' \ + | grep -E '\\.mp4$' \ + | sort -u > "${SELENOID_VIDEO_AFTER}" || true + comm -13 "${SELENOID_VIDEO_BEFORE}" "${SELENOID_VIDEO_AFTER}" > "${SELENOID_VIDEO_NEW}" || true + if [ ! -s "${SELENOID_VIDEO_NEW}" ]; then + cp "${SELENOID_VIDEO_AFTER}" "${SELENOID_VIDEO_NEW}" || true + fi + while IFS= read -r video_file; do + [ -n "${video_file}" ] || continue + downloaded=0 + for i in $(seq 1 20); do + if curl -fsS "${SELENOID_BASE}/video/${video_file}" -o "./artifacts/target/${video_file}"; then + downloaded=1 + break + fi + sleep 2 + done + if [ "${downloaded}" -ne 1 ]; then + echo "Failed to fetch Selenoid video: ${video_file}" + fi + done < "${SELENOID_VIDEO_NEW}" + fi + + ATTACHMENT_COUNT=0 + ATTACHMENTS_MANIFEST="./artifacts/target/allure-results/.external-attachments.txt" + : > "${ATTACHMENTS_MANIFEST}" + ATTACH_FILES="$(find ./artifacts -type f \\( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.mp4' -o -iname '*.webm' \\) 2>/dev/null || true)" + if [ -n "${ATTACH_FILES}" ]; then + while IFS= read -r attachment_file; do + if [ -z "${attachment_file}" ] || [ ! -f "${attachment_file}" ]; then + continue + fi + ATTACHMENT_COUNT=$((ATTACHMENT_COUNT + 1)) + ext="$(echo "${attachment_file}" | awk -F. '{print tolower($NF)}')" + mime="application/octet-stream" + case "${ext}" in + png) mime="image/png" ;; + jpg|jpeg) mime="image/jpeg" ;; + mp4) mime="video/mp4" ;; + webm) mime="video/webm" ;; + esac + source_name="external-attachment-${ATTACHMENT_COUNT}.${ext}" + cp "${attachment_file}" "./artifacts/target/allure-results/${source_name}" || continue + safe_name="$(basename "${attachment_file}" | sed 's/"/\\"/g')" + printf '%s|%s|%s\n' "${safe_name}" "${mime}" "${source_name}" >> "${ATTACHMENTS_MANIFEST}" + done < "./artifacts/target/allure-results/${EXTRA_UUID}-result.json" < ./artifacts/target/allure-results/environment.properties + trap - EXIT INT TERM + docker rm -f "${CID}" || true + exit "${TEST_RC}" + ''' + } + } + } + post { + always { + script { + try { + allure commandline: 'allure', includeProperties: false, jdk: '', results: [[path: 'artifacts/target/allure-results']] + } catch (Exception ex) { + echo "Allure publisher unavailable: ${ex.message}" + } + } + junit allowEmptyResults: true, testResults: 'artifacts/target/surefire-reports/*.xml' + archiveArtifacts allowEmptyArchive: true, artifacts: 'artifacts/run-info.txt,artifacts/target/**' + } + } +} diff --git a/config/jobs/templates/jobs-uploader.yaml b/config/jobs/templates/jobs-uploader.yaml index 6c94bbf..6c524d1 100644 --- a/config/jobs/templates/jobs-uploader.yaml +++ b/config/jobs/templates/jobs-uploader.yaml @@ -11,6 +11,6 @@ parameters: - string: name: JJB_PATH - default: /workspace/hw8/config/jobs + default: /workspace/projectwork/config/jobs description: "Путь до JJB-конфигов" dsl: !include-raw-verbatim: ../scripts/jobs-uploader.groovy diff --git a/config/jobs/templates/qa-api-contract-tests.yaml b/config/jobs/templates/qa-api-contract-tests.yaml new file mode 100644 index 0000000..c5eb90e --- /dev/null +++ b/config/jobs/templates/qa-api-contract-tests.yaml @@ -0,0 +1,11 @@ +--- +- job: + name: qa-api-contract-tests + description: "Контрактные API тесты (WireMock + JsonSchemaValidation)." + project-type: pipeline + concurrent: true + sandbox: true + properties: + - build-discarder: + num-to-keep: 50 + dsl: !include-raw-verbatim: ../scripts/qa-api-contract-tests.groovy diff --git a/config/jobs/templates/qa-hw3-api-tests.yaml b/config/jobs/templates/qa-hw3-api-tests.yaml new file mode 100644 index 0000000..6a9af97 --- /dev/null +++ b/config/jobs/templates/qa-hw3-api-tests.yaml @@ -0,0 +1,24 @@ +--- +- job: + name: qa-api-rest-tests + description: "API REST тесты (RestAssured + JsonSchema + quality gates)." + project-type: pipeline + concurrent: true + sandbox: true + properties: + - build-discarder: + num-to-keep: 50 + parameters: + - string: + name: API_REST_REPO_URL + default: https://git.kovbasa.ru/otus-autotests/hw3.git + - string: + name: API_REST_REPO_REF + default: main + - string: + name: API_REST_MAVEN_GOAL + default: verify + - string: + name: API_REST_JAVA_RELEASE + default: "21" + dsl: !include-raw-verbatim: ../scripts/qa-hw3-api-tests.groovy diff --git a/config/jobs/templates/qa-hw5-tests.yaml b/config/jobs/templates/qa-hw5-tests.yaml new file mode 100644 index 0000000..c09f5ae --- /dev/null +++ b/config/jobs/templates/qa-hw5-tests.yaml @@ -0,0 +1,21 @@ +--- +- job: + name: qa-maven-extra-tests + description: "Дополнительные Maven тесты (универсальная job)." + project-type: pipeline + concurrent: true + sandbox: true + properties: + - build-discarder: + num-to-keep: 50 + parameters: + - string: + name: EXTRA_REPO_URL + default: https://git.kovbasa.ru/otus-autotests/hw3.git + - string: + name: EXTRA_REPO_REF + default: homework_5 + - string: + name: EXTRA_MAVEN_GOAL + default: test + dsl: !include-raw-verbatim: ../scripts/qa-hw5-tests.groovy diff --git a/config/jobs/templates/qa-hw6-playwright-tests.yaml b/config/jobs/templates/qa-hw6-playwright-tests.yaml new file mode 100644 index 0000000..623dc91 --- /dev/null +++ b/config/jobs/templates/qa-hw6-playwright-tests.yaml @@ -0,0 +1,35 @@ +--- +- job: + name: qa-playwright-tests + description: "Playwright UI тесты." + project-type: pipeline + concurrent: true + sandbox: true + properties: + - build-discarder: + num-to-keep: 50 + parameters: + - string: + name: PLAYWRIGHT_REPO_URL + default: https://git.kovbasa.ru/otus-autotests/homework_6.git + - string: + name: PLAYWRIGHT_REPO_REF + default: master + - choice: + name: PLAYWRIGHT_BROWSER + choices: + - chromium + - firefox + - webkit + - choice: + name: PLAYWRIGHT_HEADLESS + choices: + - "true" + - "false" + - string: + name: PLAYWRIGHT_BASE_URL + default: https://otus.ru + - string: + name: PLAYWRIGHT_DOCKER_IMAGE + default: mcr.microsoft.com/playwright/java:v1.58.0-jammy + dsl: !include-raw-verbatim: ../scripts/qa-hw6-playwright-tests.groovy diff --git a/config/jobs/templates/qa-runner.yaml b/config/jobs/templates/qa-runner.yaml index 3886041..8cd496d 100644 --- a/config/jobs/templates/qa-runner.yaml +++ b/config/jobs/templates/qa-runner.yaml @@ -5,10 +5,22 @@ project-type: pipeline concurrent: true sandbox: true + triggers: + - timed: "15 1 * * *" properties: - build-discarder: num-to-keep: 50 parameters: + - string: + name: BDD_REPO_URL + default: https://git.kovbasa.ru/otus-autotests/homework_1.git + - string: + name: BDD_REFS + default: main,homework_2 + - string: + name: BDD_REPO_REF + default: homework_2 + description: "Legacy fallback if BDD_REFS is empty" - string: name: QA_REPO_URL default: https://git.kovbasa.ru/otus-autotests/homework_4.git @@ -41,15 +53,15 @@ default: https://otus.ru - string: name: EXECUTION_MODE - default: local + default: selenoid - string: name: SELENOID_URL default: http://host.docker.internal:4444/wd/hub - choice: name: HEADLESS choices: - - "true" - "false" + - "true" - string: name: APP_URL default: http://wiremock:8080/wishlist.apk @@ -83,4 +95,36 @@ - string: name: RESERVATION_OWNER default: user4us + - string: + name: API_REST_REPO_URL + default: https://git.kovbasa.ru/otus-autotests/hw3.git + - string: + name: API_REST_REPO_REF + default: main + - string: + name: EXTRA_REPO_URL + default: https://git.kovbasa.ru/otus-autotests/hw3.git + - string: + name: EXTRA_REPO_REF + default: homework_5 + - string: + name: PLAYWRIGHT_REPO_URL + default: https://git.kovbasa.ru/otus-autotests/homework_6.git + - string: + name: PLAYWRIGHT_REPO_REF + default: master + - choice: + name: PLAYWRIGHT_BROWSER + choices: + - chromium + - firefox + - webkit + - choice: + name: PLAYWRIGHT_HEADLESS + choices: + - "true" + - "false" + - string: + name: PLAYWRIGHT_DOCKER_IMAGE + default: mcr.microsoft.com/playwright/java:v1.58.0-jammy dsl: !include-raw-verbatim: ../scripts/qa-runner.groovy diff --git a/config/jobs/templates/qa-selenium-tests.yaml b/config/jobs/templates/qa-selenium-tests.yaml index 2dbcbd4..f9916b1 100644 --- a/config/jobs/templates/qa-selenium-tests.yaml +++ b/config/jobs/templates/qa-selenium-tests.yaml @@ -33,7 +33,7 @@ description: "Базовый URL тестируемого сайта" - string: name: EXECUTION_MODE - default: local + default: selenoid description: "Режим запуска (local|selenoid)" - string: name: SELENOID_URL @@ -42,7 +42,7 @@ - choice: name: HEADLESS choices: - - "true" - "false" + - "true" description: "Headless режим" dsl: !include-raw-verbatim: ../scripts/qa-selenium-tests.groovy diff --git a/config/jobs/templates/qa-web-bdd-tests.yaml b/config/jobs/templates/qa-web-bdd-tests.yaml new file mode 100644 index 0000000..9262be2 --- /dev/null +++ b/config/jobs/templates/qa-web-bdd-tests.yaml @@ -0,0 +1,40 @@ +--- +- job: + name: qa-web-bdd-tests + description: "WEB BDD/Cucumber тесты (homework_1, branch homework_2)." + project-type: pipeline + concurrent: true + sandbox: true + properties: + - build-discarder: + num-to-keep: 50 + parameters: + - string: + name: BDD_REPO_URL + default: https://git.kovbasa.ru/otus-autotests/homework_1.git + description: "Git URL репозитория с BDD/Cucumber web тестами" + - string: + name: BDD_REPO_REF + default: homework_2 + description: "Git branch/tag/commit для checkout" + - string: + name: ALLURE_ADAPTER_VERSION + default: 2.29.1 + description: "Версия allure-junit5 адаптера" + - choice: + name: BROWSER + choices: + - chrome + - firefox + - string: + name: BASE_URL + default: https://otus.ru + - string: + name: SELENOID_URL + default: http://host.docker.internal:4444/wd/hub + - choice: + name: HEADLESS + choices: + - "false" + - "true" + dsl: !include-raw-verbatim: ../scripts/qa-web-bdd-tests.groovy diff --git a/config/jobs/view.yaml b/config/jobs/view.yaml index 10ec713..416c50e 100644 --- a/config/jobs/view.yaml +++ b/config/jobs/view.yaml @@ -41,9 +41,14 @@ filter-executors: true filter-queue: true job-name: + - qa-web-bdd-tests - qa-selenium-tests - qa-api-citrus-tests + - qa-api-contract-tests - qa-mobile-appium-tests + - qa-api-rest-tests + - qa-maven-extra-tests + - qa-playwright-tests columns: - status - weather diff --git a/config/wiremock/mappings/user-by-id-failing.json b/config/wiremock/mappings/user-by-id-failing.json new file mode 100644 index 0000000..2396aac --- /dev/null +++ b/config/wiremock/mappings/user-by-id-failing.json @@ -0,0 +1,16 @@ +{ + "request": { + "method": "GET", + "urlPath": "/users/500" + }, + "response": { + "status": 500, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "error": "temporary_failure", + "message": "Mocked failing method for resilience checks" + } + } +} diff --git a/config/wiremock/mappings/user-by-id-success.json b/config/wiremock/mappings/user-by-id-success.json new file mode 100644 index 0000000..f501e57 --- /dev/null +++ b/config/wiremock/mappings/user-by-id-success.json @@ -0,0 +1,19 @@ +{ + "request": { + "method": "GET", + "urlPath": "/users/1" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "id": 1, + "name": "Student 1", + "grade": "A", + "school_name": "OTUS School", + "city": "Moscow" + } + } +} diff --git a/config/wiremock/mappings/users-all.json b/config/wiremock/mappings/users-all.json new file mode 100644 index 0000000..62928a1 --- /dev/null +++ b/config/wiremock/mappings/users-all.json @@ -0,0 +1,24 @@ +{ + "request": { + "method": "GET", + "urlPath": "/users/get/all" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": [ + { + "id": 1, + "name": "Ivan Ivanov", + "grade": "A" + }, + { + "id": 2, + "name": "Petr Petrov", + "grade": "B" + } + ] + } +} diff --git a/contracts-tests/pom.xml b/contracts-tests/pom.xml new file mode 100644 index 0000000..911402c --- /dev/null +++ b/contracts-tests/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + ru.otus.projectwork + contracts-tests + 1.0.0 + jar + + + 17 + UTF-8 + 5.10.2 + 5.5.1 + + + + + io.rest-assured + rest-assured + ${restassured.version} + test + + + io.rest-assured + json-schema-validator + ${restassured.version} + test + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + ${baseUrl} + + + + + + diff --git a/contracts-tests/src/test/java/ru/otus/contracts/UsersContractTest.java b/contracts-tests/src/test/java/ru/otus/contracts/UsersContractTest.java new file mode 100644 index 0000000..997725b --- /dev/null +++ b/contracts-tests/src/test/java/ru/otus/contracts/UsersContractTest.java @@ -0,0 +1,52 @@ +package ru.otus.contracts; + +import io.restassured.RestAssured; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static io.restassured.module.jsv.JsonSchemaValidator.matchesJsonSchemaInClasspath; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; + +class UsersContractTest { + + @BeforeEach + void setUp() { + RestAssured.baseURI = System.getProperty("baseUrl", "http://127.0.0.1:18080"); + } + + @Test + void shouldReturnUsersArrayByContract() { + given() + .when() + .get("/users/get/all") + .then() + .statusCode(200) + .body(matchesJsonSchemaInClasspath("schemas/users-all.schema.json")) + .body("size()", greaterThanOrEqualTo(1)); + } + + @Test + void shouldReturnUserByIdByContract() { + given() + .pathParam("id", 1) + .when() + .get("/users/{id}") + .then() + .statusCode(200) + .body(matchesJsonSchemaInClasspath("schemas/user-by-id.schema.json")) + .body("id", equalTo(1)); + } + + @Test + void shouldMockFailingMethod() { + given() + .pathParam("id", 500) + .when() + .get("/users/{id}") + .then() + .statusCode(500) + .body("error", equalTo("temporary_failure")); + } +} diff --git a/contracts-tests/src/test/resources/schemas/user-by-id.schema.json b/contracts-tests/src/test/resources/schemas/user-by-id.schema.json new file mode 100644 index 0000000..a0e86b3 --- /dev/null +++ b/contracts-tests/src/test/resources/schemas/user-by-id.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["id", "name", "grade", "school_name", "city"], + "additionalProperties": false, + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "grade": { + "type": "string" + }, + "school_name": { + "type": "string" + }, + "city": { + "type": "string" + } + } +} diff --git a/contracts-tests/src/test/resources/schemas/users-all.schema.json b/contracts-tests/src/test/resources/schemas/users-all.schema.json new file mode 100644 index 0000000..bcb7a6f --- /dev/null +++ b/contracts-tests/src/test/resources/schemas/users-all.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "name", "grade"], + "additionalProperties": false, + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "grade": { + "type": "string" + } + } + } +}