pipeline { agent { label 'maven' } options { timestamps() ansiColor('xterm') } stages { stage('Prepare Metadata') { steps { script { 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} mobile" currentBuild.description = "by=${env.RUN_TRIGGER_NAME}; repo=${params.MOBILE_REPO_URL}; ref=${params.MOBILE_REPO_REF}; emulators=${params.MOBILE_MAX_EMULATORS}; junit=${params.JUNIT_PARALLELISM}; order=${params.TEST_CLASSES_ORDER}; rerun=${params.SUREFIRE_RERUN_FAILING}" } } } stage('Prepare Workspace') { steps { sh ''' set -eux git config --global --add safe.directory '*' || true rm -rf ./project git clone "${MOBILE_REPO_URL}" ./project git -C ./project checkout "${MOBILE_REPO_REF}" GIT_SHA="$(git -C ./project rev-parse --short HEAD)" if ! grep -q "allure-junit5" ./project/pom.xml; then awk -v ver="${ALLURE_ADAPTER_VERSION}" ' index($0, "") && !done { print " " print " io.qameta.allure" print " allure-junit5" print " " ver "" print " test" print " " done=1 } { print } ' ./project/pom.xml > ./project/pom.xml.tmp mv ./project/pom.xml.tmp ./project/pom.xml fi mkdir -p ./project/target/allure-results { echo "job=${JOB_NAME}" echo "build=${BUILD_NUMBER}" echo "trigger_user=${RUN_TRIGGER_USER}" echo "trigger_name=${RUN_TRIGGER_NAME}" echo "mobile_repo_url=${MOBILE_REPO_URL}" echo "mobile_repo_ref=${MOBILE_REPO_REF}" echo "mobile_repo_sha=${GIT_SHA}" echo "app_url=${APP_URL}" echo "mobile_max_emulators=${MOBILE_MAX_EMULATORS}" echo "junit_parallelism=${JUNIT_PARALLELISM}" echo "test_classes_order=${TEST_CLASSES_ORDER}" echo "surefire_rerun_failing=${SUREFIRE_RERUN_FAILING}" echo "db_url=${DB_URL}" echo "db_user=${DB_USER}" echo "reservation_owner=${RESERVATION_OWNER}" echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" } > ./project/run-info.txt ''' } } stage('Start Mobile Environment') { steps { dir('project') { sh ''' set -eux 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 compose_cmd() { PROJECT_DIR="$PWD" docker-compose -f "${COMPOSE_FILE}" "$@"; } else echo "Neither docker compose plugin nor docker-compose binary is available" exit 1 fi export COMPOSE_PROJECT_NAME=mobileci MAX_EMULATORS="${MOBILE_MAX_EMULATORS:-1}" if ! [ "${MAX_EMULATORS}" -eq "${MAX_EMULATORS}" ] 2>/dev/null; then echo "Invalid MOBILE_MAX_EMULATORS='${MAX_EMULATORS}', expected integer" exit 1 fi if [ "${MAX_EMULATORS}" -lt 1 ] || [ "${MAX_EMULATORS}" -gt 2 ]; then echo "Invalid MOBILE_MAX_EMULATORS='${MAX_EMULATORS}', expected 1 or 2" exit 1 fi SERVICES="wiremock android-emulator-1" if [ "${MAX_EMULATORS}" -ge 2 ]; then SERVICES="${SERVICES} android-emulator-2" fi 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 if [ "${service}" = "android-emulator-2" ] && [ "${MAX_EMULATORS}" -lt 2 ]; then continue fi if [ "${SELECTED}" -ge "${MAX_EMULATORS}" ]; then break fi cid="$(compose_cmd ps -q "$service")" if [ -z "${cid}" ]; then echo "Container for ${service} not found, skipping" continue fi healthy="false" for i in $(seq 1 80); do status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}starting{{end}}' "${cid}" || true)" if [ "${status}" = "healthy" ]; then healthy="true" break fi sleep 10 done if [ "${healthy}" = "true" ]; then if [ -n "${EMULATORS}" ]; then EMULATORS="${EMULATORS}," fi EMULATORS="${EMULATORS}${service}|http://${service}:4723|Android Emulator|${APP_URL}" SELECTED=$((SELECTED + 1)) else echo "Service ${service} is not healthy in time, excluding from test run" docker logs "${cid}" || true fi done if [ -z "${EMULATORS}" ]; then echo "No healthy emulators available" exit 1 fi printf '%s' "${EMULATORS}" > .mobile_emulators.txt ''' } } } stage('Run Appium Tests In Docker') { steps { dir('project') { sh ''' set -eux rm -rf ./target ./target-run MOBILE_EMULATORS_VALUE="$(cat ./.mobile_emulators.txt)" EMULATOR_ENTRIES_COUNT="$(printf '%s' "${MOBILE_EMULATORS_VALUE}" | awk -F, '{print NF}')" DOCKER_NETWORK_ARG="--network mobileci_default" if [ "${EMULATOR_ENTRIES_COUNT}" -eq 1 ]; then SINGLE_SERVICE="$(printf '%s' "${MOBILE_EMULATORS_VALUE}" | cut -d'|' -f1)" if [ "${SINGLE_SERVICE}" = "android-emulator-1" ] || [ "${SINGLE_SERVICE}" = "android-emulator-2" ]; then SINGLE_EMULATOR_CID="$(docker ps -q --filter "name=mobileci-${SINGLE_SERVICE}" | head -n1 || true)" if [ -n "${SINGLE_EMULATOR_CID}" ]; then DOCKER_NETWORK_ARG="--network container:${SINGLE_EMULATOR_CID}" MOBILE_EMULATORS_VALUE="$(printf '%s' "${MOBILE_EMULATORS_VALUE}" | awk -F'|' 'BEGIN { OFS="|" } { $2="http://127.0.0.1:4723"; print $1, $2, $3, $4 }')" fi fi fi DB_PASSWORD_EFFECTIVE="${DB_PASSWORD:-${MOBILE_DB_PASSWORD:-}}" if [ -z "${DB_PASSWORD_EFFECTIVE}" ]; then echo "DB password is not set. Provide DB_PASSWORD job parameter or MOBILE_DB_PASSWORD env variable." exit 1 fi # Validate Appium endpoints from the same Docker network as test container. if ! docker run --rm \ ${DOCKER_NETWORK_ARG} \ -e MOBILE_EMULATORS="${MOBILE_EMULATORS_VALUE}" \ localhost:5005/otus/test-mobile:1.0.0 \ bash -lc ' set -euo pipefail IFS="," read -ra TARGETS <<< "${MOBILE_EMULATORS}" for target in "${TARGETS[@]}"; do endpoint="$(echo "${target}" | cut -d"|" -f2)" if [ -z "${endpoint}" ]; then echo "Invalid MOBILE_EMULATORS entry: ${target}" exit 1 fi ready="false" for i in $(seq 1 60); do if curl -fsS "${endpoint}/status" >/dev/null 2>&1; then ready="true" break fi sleep 2 done if [ "${ready}" != "true" ]; then echo "Appium endpoint is not reachable from test container: ${endpoint}" exit 1 fi done '; then echo "Appium preflight failed. Dumping emulator logs." for service in android-emulator-1 android-emulator-2; do cid="$(docker ps -q --filter "name=mobileci-${service}" | head -n1 || true)" if [ -n "${cid}" ]; then echo "--- logs for ${service} (${cid}) ---" docker logs --tail 200 "${cid}" || true fi 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" attempt="$2" class_key="$(echo "${class_name}" | sed 's/[^a-zA-Z0-9._-]/_/g')" attempt_dir="./target-run/${class_key}/attempt-${attempt}" mkdir -p "${attempt_dir}" CID="$(docker create \ ${DOCKER_NETWORK_ARG} \ -e DB_URL="${DB_URL:-}" \ -e DB_USER="${DB_USER:-}" \ -e DB_PASSWORD="${DB_PASSWORD_EFFECTIVE}" \ -e APP_URL="${APP_URL:-}" \ -e MOBILE_EMULATORS="${MOBILE_EMULATORS_VALUE}" \ -e WISHLISTS_USERNAME="${WISHLISTS_USERNAME:-}" \ -e WISHLISTS_PASSWORD="${WISHLISTS_PASSWORD:-}" \ -e GIFTS_USERNAME="${GIFTS_USERNAME:-}" \ -e GIFTS_PASSWORD="${GIFTS_PASSWORD:-}" \ -e RESERVATION_USERNAME="${RESERVATION_USERNAME:-}" \ -e RESERVATION_PASSWORD="${RESERVATION_PASSWORD:-}" \ -e RESERVATION_OWNER="${RESERVATION_OWNER:-}" \ localhost:5005/otus/test-mobile:1.0.0 \ bash -lc "set -e; cd /workspace; timeout 1800s mvn -Dallure.version=${ALLURE_ADAPTER_VERSION} -Djunit.jupiter.execution.parallel.enabled=false -Djunit.jupiter.execution.parallel.config.fixed.parallelism=${JUNIT_PARALLELISM:-1} -Dtest=${class_name} -Dsurefire.rerunFailingTestsCount=${SUREFIRE_RERUN_FAILING:-0} -Dallure.results.directory=target/allure-results test")" tar -C "$PWD" -cf - . | docker cp - "${CID}:/workspace" set +e docker start -a "${CID}" class_rc=$? set -e docker cp "${CID}:/workspace/target" "${attempt_dir}/target" || true docker rm -f "${CID}" >/dev/null 2>&1 || true return "${class_rc}" } collect_attempt_artifacts() { src_dir="$1" mkdir -p ./target/allure-results ./target/surefire-reports if [ -d "${src_dir}/allure-results" ]; then cp -a "${src_dir}/allure-results/." ./target/allure-results/ || true fi if [ -d "${src_dir}/surefire-reports" ]; then cp -a "${src_dir}/surefire-reports/." ./target/surefire-reports/ || true fi } if [ -n "${TEST_CLASSES_ORDER:-}" ]; then printf '%s\n' "${TEST_CLASSES_ORDER}" | tr ',' '\n' > .test-classes.txt else cat > .test-classes.txt <<'EOF' ru.otus.mobile.tests.WishlistsTest ru.otus.mobile.tests.GiftsTest ru.otus.mobile.tests.ReservationTest EOF fi TEST_RC=0 MAX_CLASS_ATTEMPTS=2 while IFS= read -r raw_class; do class_name="$(echo "${raw_class}" | xargs)" if [ -z "${class_name}" ]; then continue fi CLASS_OK=0 LAST_ATTEMPT_DIR="" for attempt in $(seq 1 "${MAX_CLASS_ATTEMPTS}"); do echo "Running ${class_name} (attempt ${attempt}/${MAX_CLASS_ATTEMPTS})" if run_single_class "${class_name}" "${attempt}"; then CLASS_OK=1 LAST_ATTEMPT_DIR="./target-run/$(echo "${class_name}" | sed 's/[^a-zA-Z0-9._-]/_/g')/attempt-${attempt}/target" collect_attempt_artifacts "${LAST_ATTEMPT_DIR}" break fi LAST_ATTEMPT_DIR="./target-run/$(echo "${class_name}" | sed 's/[^a-zA-Z0-9._-]/_/g')/attempt-${attempt}/target" sleep 3 done if [ "${CLASS_OK}" -ne 1 ]; then TEST_RC=1 if [ -n "${LAST_ATTEMPT_DIR}" ] && [ -d "${LAST_ATTEMPT_DIR}" ]; then collect_attempt_artifacts "${LAST_ATTEMPT_DIR}" fi fi done < .test-classes.txt mkdir -p ./target/mobile-debug docker network inspect mobileci_default > ./target/mobile-debug/network.inspect.json 2>&1 || true for service in android-emulator-1 android-emulator-2; do emu_cid="$(docker ps -q --filter "name=mobileci-${service}" | head -n1 || true)" if [ -n "${emu_cid}" ]; then docker inspect "${emu_cid}" > "./target/mobile-debug/${service}.inspect.json" 2>&1 || true docker logs "${emu_cid}" > "./target/mobile-debug/${service}.log" 2>&1 || true fi done GIT_SHA="$(git -C "$PWD" rev-parse --short HEAD)" mkdir -p ./target/allure-results SCREENSHOT_FILES="$(find ./target ./target-run -type f \\( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' \\) 2>/dev/null || true)" ATTACHMENT_COUNT=0 ATTACHMENTS_JSON="" if [ -n "${SCREENSHOT_FILES}" ]; then while IFS= read -r screenshot_file; do if [ -z "${screenshot_file}" ] || [ ! -f "${screenshot_file}" ]; then continue fi ATTACHMENT_COUNT=$((ATTACHMENT_COUNT + 1)) ext="$(echo "${screenshot_file}" | awk -F. '{print tolower($NF)}')" if [ "${ext}" = "jpg" ] || [ "${ext}" = "jpeg" ]; then mime="image/jpeg" else ext="png" mime="image/png" fi source_name="external-screenshot-${ATTACHMENT_COUNT}.${ext}" cp "${screenshot_file}" "./target/allure-results/${source_name}" || continue safe_name="$(basename "${screenshot_file}" | sed 's/"/\\"/g')" if [ -n "${ATTACHMENTS_JSON}" ]; then ATTACHMENTS_JSON="${ATTACHMENTS_JSON}," fi ATTACHMENTS_JSON="${ATTACHMENTS_JSON}{\"name\":\"${safe_name}\",\"type\":\"${mime}\",\"source\":\"${source_name}\"}" done < "./target/allure-results/${EXTRA_UUID}-result.json" < ./target/allure-results/environment.properties cat > ./target/allure-results/executor.json < ./target/allure-results/categories.json <<'EOF' [ { "name": "Infrastructure issues", "matchedStatuses": ["broken"], "messageRegex": ".*(Connection refused|No route to host|timed out|timeout|No healthy emulators available).*" }, { "name": "Mobile UI locator/assertion issues", "matchedStatuses": ["failed", "broken"], "messageRegex": ".*(Element not found|NoSuchElement|AssertionError).*" } ] EOF exit "${TEST_RC}" ''' } } } } post { always { dir('project') { sh ''' set +e 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 compose_cmd() { PROJECT_DIR="$PWD" docker-compose -f "${COMPOSE_FILE}" "$@"; } else echo "compose command is not available for cleanup" exit 0 fi export COMPOSE_PROJECT_NAME=mobileci compose_cmd down -v --remove-orphans || true ''' } script { try { allure commandline: 'allure', includeProperties: false, jdk: '', results: [[path: 'project/target/allure-results']] } catch (Exception ex) { echo "Allure publisher unavailable: ${ex.message}" } } junit allowEmptyResults: true, testResults: 'project/target/surefire-reports/*.xml' archiveArtifacts allowEmptyArchive: true, artifacts: 'project/run-info.txt,project/target/**' } } }