commit b88d0bdd81bc2f1f0221f815b49ab60ddecfa317 Author: spawn Date: Tue Apr 7 00:53:07 2026 +0300 hw7 mobile tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a2bb283 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +target/ +build/ +.idea/ +*.iml +.DS_Store +logcat.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..9aa2ac0 --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# OTUS Homework 7: Mobile (Selenide + Appium) + +Автотесты мобильного приложения Wishlist через Selenide + Appium и инфраструктура эмулятора через Docker Compose. + +## Что реализовано +- 3 сценария из ТЗ: списки желаний, подарки, резервирование подарка другого пользователя. +- Архитектура: базовый тест + экранные объекты (screens). +- Конфигурируемые capabilities через system properties. +- Подготовка тестовых данных через БД перед каждым тестом. +- Скрипты для сбора Logcat. + +## Стек и версии +- Java 21, Maven. +- Selenide: 7.3.1 +- Appium Java Client: 9.3.0 (UiAutomator2) +- Selenium: 4.25.0 +- JUnit: 5.10.2 +- PostgreSQL JDBC: 42.7.10 +- apk-parser: 2.6.10 +- SLF4J: 2.0.13 +- Docker Compose (эмулятор Android + Appium) + +## Структура проекта +- `src/test/java/ru/otus/mobile/tests` — тесты. +- `src/test/java/ru/otus/mobile/screens` — экранные объекты. +- `src/test/java/ru/otus/mobile/config` — конфигурация Appium/Android. +- `src/test/java/ru/otus/mobile/driver` — фабрика драйвера. +- `src/test/java/ru/otus/mobile/db` — reset SQL и JDBC-клиент. +- `docker-compose.yml` — эмулятор Android с Appium. +- `scripts/` — утилиты (Logcat). + +## Инфраструктура (Docker Compose) +Эмулятор и Appium поднимаются одной командой: +```bash +docker compose up -d +``` + +Порты: +- Appium: `http://localhost:4723` +- noVNC: `http://localhost:6080` + +APK берется из файла `./wishlist-349317-5fd795.apk`, копируется в `./apk/wishlist.apk` и монтируется в контейнер по пути `/apk/wishlist.apk`. +После старта контейнеров APK автоматически устанавливается в эмулятор и приложение стартует. + +## Подготовка данных через БД +Для повторяемости тестов перед каждым тестом выполняется SQL‑сброс данных аккаунта. +Данные доступа к БД **не хранятся в репозитории** — нужно передать через env или `-D`. +Аккаунты можно создать заранее (регистрация через приложение) или доверить это автотестам. +Если логин/пароль не переданы через параметры запуска, тест сгенерирует логины, создаст пользователей через reset‑SQL в БД (если их нет), затем выполнит логин. Регистрация через приложение используется как fallback, если вход не удался. + +Переменные/свойства: +- `DB_URL` или `-Ddb.url` +- `DB_USER` или `-Ddb.user` +- `DB_PASSWORD` или `-Ddb.password` +- `DB_RESET_SQL` или `-Ddb.reset.sql` — SQL c параметрами `?` (username, email, password_hash, username). Если не задано, используется встроенный reset‑SQL (удаляет wishlists/gifts и создает пользователя при отсутствии). + +Пример (PowerShell, значения подставить свои): +```powershell +$env:DB_URL = "jdbc:postgresql://:5432/" +$env:DB_USER = "" +$env:DB_PASSWORD = "" +$env:DB_RESET_SQL = "" +``` + +## Как запускать +### Полный прогон (обязательная последовательность) +1) Поднять инфраструктуру: +```bash +docker compose up -d +``` +Убедиться, что Docker Desktop/Engine запущен. Если Appium недоступен, тесты падают быстро с понятной ошибкой. + +2) Задать доступ к БД (обязательно, иначе тесты упадут): +```powershell +$env:DB_URL="jdbc:postgresql://sql.otus.kartushin.su:5432/wishlist" +$env:DB_USER="student" +$env:DB_PASSWORD="student" +``` + +3) Запустить все тесты (логины/пароли можно не задавать): +```bash +mvn test +``` + +Если логины не заданы, они будут сгенерированы автоматически, пользователь будет создан через БД (если отсутствует) и выполнится вход. +Регистрация через приложение используется только как fallback, если вход не удался. + +Опционально: задать логины/пароли тестовых пользователей (если зарегистрированы вручную): +```bash +mvn "-Dlogin.username.wishlists=" "-Dlogin.password.wishlists=" \ + "-Dlogin.username.gifts=" "-Dlogin.password.gifts=" \ + "-Dlogin.username.reservation=" "-Dlogin.password.reservation=" \ + "-Dreservation.owner=" \ + test +``` + +### Один тест +```bash +mvn "-Dtest=ru.otus.mobile.tests.WishlistsTest" test +``` + +### Запуск через Docker Compose +```bash +mvn -Dappium.url=http://localhost:4723 -Dapp.path=/apk/wishlist.apk -Dapp.package=ru.otus.wishlist -Dapp.activity=ru.otus.wishlist.MainActivity test +``` + +### Полный пример запуска (Appium + БД) +```bash +mvn -Dappium.url=http://localhost:4723 -Dapp.path=/apk/wishlist.apk -Dapp.package=ru.otus.wishlist -Dapp.activity=ru.otus.wishlist.MainActivity -Ddb.url=jdbc:postgresql://:5432/ -Ddb.user= -Ddb.password= test +``` + +## Параметры запуска +- `-Dappium.url` — URL Appium (default: `http://localhost:4723`) +- `-Dapp.path` — путь до APK (default: `/apk/wishlist.apk`) +- `-Dapp.package` — package приложения (если APK недоступен локально) +- `-Dapp.activity` — main activity (если APK недоступен локально) +- `-Ddevice.name` — имя устройства (default: `Android Emulator`) +- `-Dplatform.version` — версия Android (опционально) +- `-Dudid` — UDID (опционально) +- `-DnoReset` — не сбрасывать состояние (default: `false`) +- `-DnewCommandTimeout` — timeout Appium, сек (default: `120`) +- `-Dlogin.username.` — логин для конкретного теста (например `login.username.wishlists`) +- `-Dlogin.password` — пароль (default: `Admin123`) +- `-Dlogin.password.hash` — bcrypt‑хэш пароля для reset‑SQL (default в коде) + +## Сбор Logcat +PowerShell: +```powershell +.\scripts\collect-logcat.ps1 +``` + +Bash: +```bash +./scripts/collect-logcat.sh +``` + +Логи сохраняются в файл `logcat.txt` в корне проекта. diff --git a/apk/.gitkeep b/apk/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apk/.gitkeep @@ -0,0 +1 @@ + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7a02390 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,66 @@ +services: + apk-downloader: + image: curlimages/curl:8.5.0 + environment: + - APP_URL=${APP_URL} + command: + - sh + - -c + - > + if [ -n "$APP_URL" ]; then + curl -L "$APP_URL" -o /apk/wishlist.apk; + else + cp /apk-source/app.apk /apk/wishlist.apk; + fi + volumes: + - ./apk:/apk + - ./wishlist-349317-5fd795.apk:/apk-source/app.apk:ro + android-emulator: + image: budtmo/docker-android:emulator_13.0 + privileged: true + depends_on: + apk-downloader: + condition: service_completed_successfully + ports: + - "4723:4723" + - "6080:6080" + - "5554:5554" + - "5555:5555" + environment: + - DEVICE=Pixel_5 + - APPIUM=true + - WEB_VNC=true + - AUTO_GRANT_PERMISSIONS=true + - ENABLE_VNC=true + - EMULATOR_PARAMS=-no-window -no-audio -gpu swiftshader_indirect -no-snapshot -no-boot-anim -accel off + shm_size: 2gb + volumes: + - ./apk:/apk:ro + apk-installer: + image: budtmo/docker-android:emulator_13.0 + depends_on: + android-emulator: + condition: service_started + entrypoint: + - /bin/bash + - -lc + command: + - > + for i in $(seq 1 60); do + adb connect android-emulator:5555 || true; + if adb devices | tr -s ' ' | grep -q "android-emulator:5555"; then + break; + fi; + sleep 2; + done; + adb -s android-emulator:5555 wait-for-device; + for i in $(seq 1 120); do + if adb -s android-emulator:5555 shell getprop sys.boot_completed | tr -d '\r' | grep -q "1"; then + break; + fi; + sleep 2; + done; + adb -s android-emulator:5555 install -r /apk/wishlist.apk && + adb -s android-emulator:5555 shell monkey -p ru.otus.wishlist -c android.intent.category.LAUNCHER 1 + volumes: + - ./apk:/apk:ro diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..fb7598f --- /dev/null +++ b/pom.xml @@ -0,0 +1,84 @@ + + + 4.0.0 + + ru.otus + hw7 + 1.0-SNAPSHOT + + + 21 + 21 + UTF-8 + 7.3.1 + 9.3.0 + 5.10.2 + 2.6.10 + 2.0.13 + 4.25.0 + + + + + + org.seleniumhq.selenium + selenium-bom + ${selenium.version} + pom + import + + + + + + + com.codeborne + selenide + ${selenide.version} + + + io.appium + java-client + ${appium.version} + + + net.dongliu + apk-parser + ${apkparser.version} + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.slf4j + slf4j-simple + ${slf4j.version} + test + + + org.postgresql + postgresql + 42.7.10 + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + false + + + + + + diff --git a/src/test/java/ru/otus/mobile/config/ApkInfo.java b/src/test/java/ru/otus/mobile/config/ApkInfo.java new file mode 100644 index 0000000..a16ef95 --- /dev/null +++ b/src/test/java/ru/otus/mobile/config/ApkInfo.java @@ -0,0 +1,147 @@ +package ru.otus.mobile.config; + +import net.dongliu.apk.parser.ApkFile; +import net.dongliu.apk.parser.bean.ApkMeta; + +import java.io.IOException; +import java.io.StringReader; +import java.nio.file.Path; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; + +public final class ApkInfo { + private final String packageName; + private final String mainActivity; + + private ApkInfo(String packageName, String mainActivity) { + this.packageName = packageName; + this.mainActivity = mainActivity; + } + + public String packageName() { + return packageName; + } + + public String mainActivity() { + return mainActivity; + } + + public static ApkInfo fromApk(Path apkPath) { + try (ApkFile apkFile = new ApkFile(apkPath.toFile())) { + ApkMeta meta = apkFile.getApkMeta(); + String pkg = meta.getPackageName(); + String activity = extractLaunchableActivity(apkFile.getManifestXml(), pkg); + return new ApkInfo(pkg, activity); + } catch (IOException e) { + throw new IllegalStateException("Failed to parse APK: " + apkPath, e); + } + } + + private static String extractLaunchableActivity(String manifestXml, String pkg) { + if (manifestXml == null || manifestXml.isBlank()) { + return null; + } + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + try { + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + } catch (Exception ignored) { + // Not all parsers support this; safe processing is still enabled. + } + DocumentBuilder builder = factory.newDocumentBuilder(); + Document doc = builder.parse(new InputSource(new StringReader(manifestXml))); + + String activity = findLaunchableActivity(doc, "activity", "name", pkg); + if (activity != null) { + return activity; + } + return findLaunchableActivity(doc, "activity-alias", "targetActivity", pkg); + } catch (Exception e) { + return null; + } + } + + private static String findLaunchableActivity( + Document doc, + String tagName, + String nameAttr, + String pkg + ) { + NodeList activities = doc.getElementsByTagName(tagName); + for (int i = 0; i < activities.getLength(); i++) { + Element activity = (Element) activities.item(i); + if (!isLaunchable(activity)) { + continue; + } + String raw = androidAttr(activity, nameAttr); + if (raw == null || raw.isBlank()) { + raw = androidAttr(activity, "name"); + } + if (raw == null || raw.isBlank()) { + continue; + } + return normalizeActivityName(raw, pkg); + } + return null; + } + + private static boolean isLaunchable(Element activity) { + NodeList filters = activity.getElementsByTagName("intent-filter"); + for (int i = 0; i < filters.getLength(); i++) { + Element filter = (Element) filters.item(i); + if (hasIntentAction(filter, "android.intent.action.MAIN") + && hasIntentCategory(filter, "android.intent.category.LAUNCHER")) { + return true; + } + } + return false; + } + + private static boolean hasIntentAction(Element filter, String name) { + NodeList actions = filter.getElementsByTagName("action"); + for (int i = 0; i < actions.getLength(); i++) { + Element action = (Element) actions.item(i); + if (name.equals(androidAttr(action, "name"))) { + return true; + } + } + return false; + } + + private static boolean hasIntentCategory(Element filter, String name) { + NodeList categories = filter.getElementsByTagName("category"); + for (int i = 0; i < categories.getLength(); i++) { + Element category = (Element) categories.item(i); + if (name.equals(androidAttr(category, "name"))) { + return true; + } + } + return false; + } + + private static String androidAttr(Element element, String localName) { + String value = element.getAttributeNS("http://schemas.android.com/apk/res/android", localName); + if (value != null && !value.isBlank()) { + return value; + } + value = element.getAttribute("android:" + localName); + return value != null && !value.isBlank() ? value : null; + } + + private static String normalizeActivityName(String raw, String pkg) { + if (raw.startsWith(".")) { + return pkg + raw; + } + if (raw.contains(".")) { + return raw; + } + return pkg + "." + raw; + } +} diff --git a/src/test/java/ru/otus/mobile/config/AuthContext.java b/src/test/java/ru/otus/mobile/config/AuthContext.java new file mode 100644 index 0000000..99128c8 --- /dev/null +++ b/src/test/java/ru/otus/mobile/config/AuthContext.java @@ -0,0 +1,28 @@ +package ru.otus.mobile.config; + +public final class AuthContext { + private static String username; + private static String password; + private static boolean allowRegister; + + private AuthContext() { + } + + public static void init(String username, String password, boolean allowRegister) { + AuthContext.username = username; + AuthContext.password = password; + AuthContext.allowRegister = allowRegister; + } + + public static String username() { + return username; + } + + public static String password() { + return password; + } + + public static boolean allowRegister() { + return allowRegister; + } +} diff --git a/src/test/java/ru/otus/mobile/config/MobileConfig.java b/src/test/java/ru/otus/mobile/config/MobileConfig.java new file mode 100644 index 0000000..7c5d98a --- /dev/null +++ b/src/test/java/ru/otus/mobile/config/MobileConfig.java @@ -0,0 +1,118 @@ +package ru.otus.mobile.config; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +public final class MobileConfig { + private final String appiumUrl; + private final String appPath; + private final String deviceName; + private final String platformVersion; + private final String udid; + private final boolean noReset; + private final int newCommandTimeoutSeconds; + private final String appPackage; + private final String appActivity; + + private MobileConfig( + String appiumUrl, + String appPath, + String deviceName, + String platformVersion, + String udid, + boolean noReset, + int newCommandTimeoutSeconds, + String appPackage, + String appActivity + ) { + this.appiumUrl = appiumUrl; + this.appPath = appPath; + this.deviceName = deviceName; + this.platformVersion = platformVersion; + this.udid = udid; + this.noReset = noReset; + this.newCommandTimeoutSeconds = newCommandTimeoutSeconds; + this.appPackage = appPackage; + this.appActivity = appActivity; + } + + public String appiumUrl() { + return appiumUrl; + } + + public String appPath() { + return appPath; + } + + public String deviceName() { + return deviceName; + } + + public String platformVersion() { + return platformVersion; + } + + public String udid() { + return udid; + } + + public boolean noReset() { + return noReset; + } + + public int newCommandTimeoutSeconds() { + return newCommandTimeoutSeconds; + } + + public String appPackage() { + return appPackage; + } + + public String appActivity() { + return appActivity; + } + + public static MobileConfig load() { + String appiumUrl = System.getProperty("appium.url", "http://localhost:4723"); + String appPath = System.getProperty("app.path", "/apk/wishlist.apk"); + String deviceName = System.getProperty("device.name", "Android Emulator"); + String platformVersion = System.getProperty("platform.version"); + String udid = System.getProperty("udid"); + boolean noReset = Boolean.parseBoolean(System.getProperty("noReset", "false")); + int newCommandTimeoutSeconds = Integer.parseInt(System.getProperty("newCommandTimeout", "120")); + + String appPackage = System.getProperty("app.package"); + String appActivity = System.getProperty("app.activity"); + + Path apkPath = Paths.get(appPath); + if ((appPackage == null || appActivity == null) && Files.exists(apkPath)) { + ApkInfo apkInfo = ApkInfo.fromApk(apkPath); + if (appPackage == null) { + appPackage = apkInfo.packageName(); + } + if (appActivity == null && apkInfo.mainActivity() != null) { + appActivity = apkInfo.mainActivity(); + } + } + + if (appPackage == null) { + appPackage = "ru.otus.wishlist"; + } + if (appActivity == null) { + appActivity = "ru.otus.wishlist.MainActivity"; + } + + return new MobileConfig( + appiumUrl, + appPath, + deviceName, + platformVersion, + udid, + noReset, + newCommandTimeoutSeconds, + appPackage, + appActivity + ); + } +} diff --git a/src/test/java/ru/otus/mobile/config/MobileContext.java b/src/test/java/ru/otus/mobile/config/MobileContext.java new file mode 100644 index 0000000..8156bdc --- /dev/null +++ b/src/test/java/ru/otus/mobile/config/MobileContext.java @@ -0,0 +1,16 @@ +package ru.otus.mobile.config; + +public final class MobileContext { + private static String packageName; + + private MobileContext() { + } + + public static void init(MobileConfig config) { + packageName = config.appPackage(); + } + + public static String packageName() { + return packageName; + } +} diff --git a/src/test/java/ru/otus/mobile/db/DbClient.java b/src/test/java/ru/otus/mobile/db/DbClient.java new file mode 100644 index 0000000..3e06d24 --- /dev/null +++ b/src/test/java/ru/otus/mobile/db/DbClient.java @@ -0,0 +1,105 @@ +package ru.otus.mobile.db; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.SQLException; + +public final class DbClient { + private final DbConfig config; + private static final String DEFAULT_PASSWORD_HASH = + "$2a$10$MUT3Px5CK0cy.vTesX9NaOpWAcbkrZHECe67Qwbh19sx3AlS8bK2C"; + + public DbClient(DbConfig config) { + this.config = config; + } + + public void resetUserData(String username) { + resetUserData(username, config.resetSql()); + } + + public void resetUserData(String username, String sql) { + try (Connection connection = DriverManager.getConnection( + config.url(), + config.user(), + config.password() + )) { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + int placeholders = countPlaceholders(sql); + String passwordHash = System.getProperty("login.password.hash", DEFAULT_PASSWORD_HASH); + String email = username + "@test.local"; + if (placeholders >= 1) { + statement.setString(1, username); + } + if (placeholders >= 2) { + statement.setString(2, email); + } + if (placeholders >= 3) { + statement.setString(3, passwordHash); + } + if (placeholders >= 4) { + statement.setString(4, username); + } + statement.execute(); + } + } catch (SQLException e) { + throw new IllegalStateException("Failed to reset data for user: " + username, e); + } + } + + public void resetReservationData(String username, String ownerUsername, String sql) { + try (Connection connection = DriverManager.getConnection( + config.url(), + config.user(), + config.password() + )) { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + String passwordHash = System.getProperty("login.password.hash", DEFAULT_PASSWORD_HASH); + statement.setString(1, ownerUsername); + statement.setString(2, ownerUsername + "@test.local"); + statement.setString(3, passwordHash); + statement.setString(4, ownerUsername); + statement.setString(5, username); + statement.setString(6, username + "@test.local"); + statement.setString(7, passwordHash); + statement.setString(8, username); + statement.execute(); + } + } catch (SQLException e) { + throw new IllegalStateException("Failed to reset data for reservation user: " + username, e); + } + } + + public void updateWishlistTitle(String username, String oldTitle, String newTitle) { + String sql = """ + UPDATE wishlists + SET title = ? + WHERE user_id = (SELECT id FROM users WHERE username = ?) + AND title = ?; + """; + try (Connection connection = DriverManager.getConnection( + config.url(), + config.user(), + config.password() + )) { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, newTitle); + statement.setString(2, username); + statement.setString(3, oldTitle); + statement.executeUpdate(); + } + } catch (SQLException e) { + throw new IllegalStateException("Failed to update wishlist title for user: " + username, e); + } + } + + private int countPlaceholders(String sql) { + int count = 0; + for (int i = 0; i < sql.length(); i++) { + if (sql.charAt(i) == '?') { + count++; + } + } + return count; + } +} diff --git a/src/test/java/ru/otus/mobile/db/DbConfig.java b/src/test/java/ru/otus/mobile/db/DbConfig.java new file mode 100644 index 0000000..f91c9c6 --- /dev/null +++ b/src/test/java/ru/otus/mobile/db/DbConfig.java @@ -0,0 +1,85 @@ +package ru.otus.mobile.db; + +public final class DbConfig { + private final String url; + private final String user; + private final String password; + private final String resetSql; + + private static final String DEFAULT_RESET_SQL = """ + WITH target_user AS ( + SELECT id FROM users WHERE username = ? + ), + user_insert AS ( + INSERT INTO users (id, email, password, username) + SELECT gen_random_uuid(), ?, ?, ? + WHERE NOT EXISTS (SELECT 1 FROM target_user) + RETURNING id + ), + user_ref AS ( + SELECT id FROM target_user + UNION ALL + SELECT id FROM user_insert + ), + del_gifts AS ( + DELETE FROM gifts + WHERE wish_id IN (SELECT id FROM wishlists WHERE user_id IN (SELECT id FROM user_ref)) + ), + del_wishlists AS ( + DELETE FROM wishlists WHERE user_id IN (SELECT id FROM user_ref) + ) + SELECT 1; + """; + + private DbConfig(String url, String user, String password, String resetSql) { + this.url = url; + this.user = user; + this.password = password; + this.resetSql = resetSql; + } + + public String url() { + return url; + } + + public String user() { + return user; + } + + public String password() { + return password; + } + + public String resetSql() { + return resetSql; + } + + public static DbConfig load() { + String url = propOrEnv("db.url", "DB_URL"); + String user = propOrEnv("db.user", "DB_USER"); + String password = propOrEnv("db.password", "DB_PASSWORD"); + String resetSql = propOrEnv("db.reset.sql", "DB_RESET_SQL"); + + if (isBlank(url) || isBlank(user) || isBlank(password)) { + throw new IllegalStateException( + "DB config is missing. Provide db.url/db.user/db.password or DB_URL/DB_USER/DB_PASSWORD." + ); + } + if (isBlank(resetSql)) { + resetSql = DEFAULT_RESET_SQL; + } + return new DbConfig(url, user, password, resetSql); + } + + private static String propOrEnv(String prop, String env) { + String value = System.getProperty(prop); + if (!isBlank(value)) { + return value; + } + return System.getenv(env); + } + + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/src/test/java/ru/otus/mobile/driver/MobileDriverFactory.java b/src/test/java/ru/otus/mobile/driver/MobileDriverFactory.java new file mode 100644 index 0000000..0007ed0 --- /dev/null +++ b/src/test/java/ru/otus/mobile/driver/MobileDriverFactory.java @@ -0,0 +1,103 @@ +package ru.otus.mobile.driver; + +import io.appium.java_client.android.AndroidDriver; +import io.appium.java_client.android.options.UiAutomator2Options; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebDriverException; +import ru.otus.mobile.config.MobileConfig; + +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.time.Duration; + +public final class MobileDriverFactory { + private MobileDriverFactory() { + } + + public static WebDriver create(MobileConfig config) { + UiAutomator2Options options = new UiAutomator2Options() + .setPlatformName("Android") + .setAutomationName("UiAutomator2") + .setDeviceName(config.deviceName()) + .setApp(config.appPath()) + .setAppPackage(config.appPackage()) + .setAppActivity(config.appActivity()) + .setAutoGrantPermissions(true) + .setNewCommandTimeout(Duration.ofSeconds(config.newCommandTimeoutSeconds())) + .setAppWaitActivity("*"); + + if (config.platformVersion() != null && !config.platformVersion().isBlank()) { + options.setPlatformVersion(config.platformVersion()); + } + if (config.udid() != null && !config.udid().isBlank()) { + options.setUdid(config.udid()); + } + options.setNoReset(config.noReset()); + options.setCapability("appium:settingsAppWaitDuration", 60000); + options.setCapability("appium:uiautomator2ServerInstallTimeout", 60000); + options.setCapability("appium:uiautomator2ServerLaunchTimeout", 60000); + options.setCapability("appium:appWaitDuration", 60000); + + try { + URL appiumUrl = new URL(config.appiumUrl()); + assertAppiumReady(appiumUrl); + return createWithDeviceRetry(appiumUrl, options); + } catch (MalformedURLException e) { + throw new IllegalStateException("Invalid Appium URL: " + config.appiumUrl(), e); + } + } + + private static WebDriver createWithDeviceRetry(URL appiumUrl, UiAutomator2Options options) { + long deadline = System.currentTimeMillis() + 180_000; + WebDriverException last = null; + while (System.currentTimeMillis() < deadline) { + try { + return new AndroidDriver(appiumUrl, options); + } catch (WebDriverException e) { + last = e; + String message = e.getMessage(); + if (message != null && message.contains("Could not find a connected Android device")) { + try { + Thread.sleep(1000); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + break; + } + continue; + } + if (message != null && message.contains("Appium Settings app is not running")) { + try { + Thread.sleep(3000); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + break; + } + continue; + } + throw e; + } + } + if (last != null) { + throw last; + } + throw new WebDriverException("Android device was not detected within timeout."); + } + + private static void assertAppiumReady(URL appiumUrl) { + try { + URL statusUrl = new URL(appiumUrl.toString() + "/status"); + HttpURLConnection connection = (HttpURLConnection) statusUrl.openConnection(); + connection.setConnectTimeout(3000); + connection.setReadTimeout(3000); + connection.setRequestMethod("GET"); + int code = connection.getResponseCode(); + if (code >= 400) { + throw new IllegalStateException("Appium status returned HTTP " + code); + } + } catch (Exception e) { + throw new WebDriverException("Appium is not reachable at " + appiumUrl + ". " + + "Make sure docker compose is up and Appium is healthy.", e); + } + } +} diff --git a/src/test/java/ru/otus/mobile/screens/GiftsScreen.java b/src/test/java/ru/otus/mobile/screens/GiftsScreen.java new file mode 100644 index 0000000..9f102dc --- /dev/null +++ b/src/test/java/ru/otus/mobile/screens/GiftsScreen.java @@ -0,0 +1,77 @@ +package ru.otus.mobile.screens; + +import com.codeborne.selenide.ElementsCollection; +import com.codeborne.selenide.SelenideElement; +import io.appium.java_client.AppiumBy; +import org.openqa.selenium.WebElement; +import ru.otus.mobile.ui.MobileUi; + +public class GiftsScreen { + + public void open() { + if (MobileUi.existsById("gifts_content")) { + return; + } + if (MobileUi.existsById("gift_item") + || MobileUi.existsById("list_item")) { + return; + } + MobileUi.byIdOrAccessibility("gifts_content"); + } + + public void createGift(String name) { + if (!MobileUi.existsById("add_button")) { + open(); + } + MobileUi.byIdOrAccessibility("add_button").click(); + typeInto("name_input", name); + typeInto("price_input", "100"); + MobileUi.byIdOrAccessibility("save_button").click(); + } + + public void editGift(String oldName, String newName) { + openGift(oldName); + if (MobileUi.existsById("edit_button")) { + MobileUi.byIdOrAccessibility("edit_button").click(); + } + typeInto("name_input", newName); + MobileUi.byIdOrAccessibility("save_button").click(); + } + + public void assertGiftPresent(String name) { + scrollToGiftName(name); + } + + private void openGift(String name) { + SelenideElement byText = MobileUi.byTextContains(name); + if (byText.exists()) { + byText.click(); + return; + } + scrollToGiftName(name).click(); + if (MobileUi.existsById("edit_button") + || MobileUi.existsById("gift_edit_bottom_sheet")) { + return; + } + ElementsCollection items = MobileUi.allById("gift_item"); + if (items.isEmpty()) { + items = MobileUi.allById("list_item"); + } + items.first().click(); + } + + private SelenideElement scrollToGiftName(String name) { + String safe = name.replace("\"", "\\\""); + String selector = "new UiScrollable(new UiSelector().scrollable(true))" + + ".scrollIntoView(new UiSelector().textContains(\"" + safe + "\"))"; + return com.codeborne.selenide.Selenide.$(AppiumBy.androidUIAutomator(selector)); + } + + private void typeInto(String id, String value) { + WebElement input = MobileUi.byIdOrAccessibility(id) + .toWebElement(); + input.click(); + input.clear(); + input.sendKeys(value); + } +} diff --git a/src/test/java/ru/otus/mobile/screens/LoginScreen.java b/src/test/java/ru/otus/mobile/screens/LoginScreen.java new file mode 100644 index 0000000..470ecbf --- /dev/null +++ b/src/test/java/ru/otus/mobile/screens/LoginScreen.java @@ -0,0 +1,194 @@ +package ru.otus.mobile.screens; + +import org.openqa.selenium.WebElement; +import ru.otus.mobile.ui.MobileUi; + +public class LoginScreen { + + public boolean isVisible() { + return MobileUi.existsById("username_text_input") + || MobileUi.existsById("password_text_input"); + } + + public void loginOrRegister(String username, String password, boolean allowRegister) { + if (!isVisible()) { + return; + } + attemptLogin(username, password); + if (!isVisible()) { + return; + } + dismissDialogIfPresent(); + if (!isVisible()) { + return; + } + if (allowRegister) { + if (isRegistrationScreen()) { + completeRegistration(username, password); + ensureLoginCompleted(); + return; + } + if (openRegistrationScreen()) { + completeRegistration(username, password); + ensureLoginCompleted(); + return; + } + } + if (isVisible()) { + throw new IllegalStateException( + "Login did not complete. Provide valid credentials or enable registration." + ); + } + } + + private boolean isRegistrationScreen() { + return MobileUi.existsById("email_text_input") + || MobileUi.byTextContains("Регистрация").exists(); + } + + private boolean openRegistrationScreen() { + if (clickRegisterLink()) { + return MobileUi.existsById("email_text_input"); + } + return false; + } + + private boolean clickRegisterLink() { + if (MobileUi.existsById("register_button")) { + MobileUi.byIdOrAccessibility("register_button").click(); + return true; + } + if (MobileUi.byTextContains("Регистрац").exists()) { + MobileUi.byTextContains("Регистрац").click(); + return true; + } + if (MobileUi.byTextContains("Зарегистр").exists()) { + MobileUi.byTextContains("Зарегистр").click(); + return true; + } + MobileUi.hideKeyboard(); // hide keyboard to reveal link + if (MobileUi.byTextContains("Регистрац").exists()) { + MobileUi.byTextContains("Регистрац").click(); + return true; + } + if (MobileUi.byTextContains("Зарегистр").exists()) { + MobileUi.byTextContains("Зарегистр").click(); + return true; + } + return false; + } + + private void completeRegistration(String username, String password) { + typeInto("username_text_input", username); + if (MobileUi.existsById("email_text_input")) { + typeInto("email_text_input", username + "@test.local"); + } + if (MobileUi.existsById("password_text_input")) { + typeInto("password_text_input", password); + } + fillRepeatPasswordIfPresent(password); + MobileUi.hideKeyboard(); // hide keyboard so buttons are visible + if (MobileUi.existsById("register_button")) { + MobileUi.byIdOrAccessibility("register_button").click(); + dismissDialogIfPresent(); + finishLoginAfterRegistration(username, password); + return; + } + if (MobileUi.byTextContains("ЗАРЕГИСТРИРОВАТЬСЯ").exists()) { + MobileUi.byTextContains("ЗАРЕГИСТРИРОВАТЬСЯ").click(); + dismissDialogIfPresent(); + finishLoginAfterRegistration(username, password); + } + } + + private void finishLoginAfterRegistration(String username, String password) { + if (MobileUi.existsById("log_in_button") || isLoginScreen()) { + typeInto("username_text_input", username); + if (MobileUi.existsById("password_text_input")) { + typeInto("password_text_input", password); + } + MobileUi.hideKeyboard(); + if (MobileUi.existsById("log_in_button")) { + MobileUi.byIdOrAccessibility("log_in_button").click(); + } + return; + } + if (isRegistrationScreen()) { + MobileUi.back(); + if (isLoginScreen()) { + typeInto("username_text_input", username); + if (MobileUi.existsById("password_text_input")) { + typeInto("password_text_input", password); + } + MobileUi.hideKeyboard(); + if (MobileUi.existsById("log_in_button")) { + MobileUi.byIdOrAccessibility("log_in_button").click(); + } + } + if (isRegistrationScreen()) { + throw new IllegalStateException("Registration did not complete. Check password rules and required fields."); + } + } + } + + private boolean isLoginScreen() { + return MobileUi.existsById("username_text_input") + && !MobileUi.existsById("email_text_input"); + } + + private void typeInto(String id, String value) { + WebElement input = MobileUi.byIdOrAccessibility(id) + .toWebElement(); + input.click(); + input.clear(); + input.sendKeys(value); + } + + private void attemptLogin(String username, String password) { + if (!MobileUi.existsById("username_text_input")) { + return; + } + typeInto("username_text_input", username); + if (MobileUi.existsById("password_text_input")) { + typeInto("password_text_input", password); + } + MobileUi.hideKeyboard(); + if (MobileUi.existsById("log_in_button")) { + MobileUi.byIdOrAccessibility("log_in_button").click(); + } + dismissDialogIfPresent(); + } + + private void fillRepeatPasswordIfPresent(String password) { + String[] repeatIds = { + "repeat_password_text_input", + "password_repeat_text_input", + "confirm_password_text_input", + "password_confirm_text_input" + }; + for (String id : repeatIds) { + if (MobileUi.existsById(id)) { + typeInto(id, password); + return; + } + } + } + + private void dismissDialogIfPresent() { + if (MobileUi.existsById("android:id/button1")) { + MobileUi.byIdOrAccessibility("android:id/button1").click(); + return; + } + if (MobileUi.byTextContains("OK").exists()) { + MobileUi.byTextContains("OK").click(); + return; + } + if (MobileUi.byTextContains("ОК").exists()) { + MobileUi.byTextContains("ОК").click(); + } + } + + private void ensureLoginCompleted() { + dismissDialogIfPresent(); + } +} diff --git a/src/test/java/ru/otus/mobile/screens/UsersScreen.java b/src/test/java/ru/otus/mobile/screens/UsersScreen.java new file mode 100644 index 0000000..e8cfd09 --- /dev/null +++ b/src/test/java/ru/otus/mobile/screens/UsersScreen.java @@ -0,0 +1,180 @@ +package ru.otus.mobile.screens; + +import com.codeborne.selenide.ElementsCollection; +import com.codeborne.selenide.SelenideElement; +import io.appium.java_client.AppiumBy; +import ru.otus.mobile.config.AuthContext; +import ru.otus.mobile.config.MobileContext; +import ru.otus.mobile.ui.MobileUi; +import ru.otus.mobile.ui.UiWait; + +import static com.codeborne.selenide.Selenide.$; + +public class UsersScreen { + + public void open() { + ensureLoggedIn(); + if (MobileUi.existsById("users_content")) { + return; + } + dismissErrorDialogIfPresent(); + if (MobileUi.existsById("users_menu")) { + MobileUi.byIdOrAccessibility("users_menu").click(); + } else if (MobileUi.existsById("go_to_users_tab")) { + MobileUi.byIdOrAccessibility("go_to_users_tab").click(); + } else if (MobileUi.existsById("go_to_users")) { + MobileUi.byIdOrAccessibility("go_to_users").click(); + } + MobileUi.byIdOrAccessibility("users_content"); + } + + private void dismissErrorDialogIfPresent() { + if (UiWait.exists(AppiumBy.id("android:id/alertTitle")) + || UiWait.exists(AppiumBy.id("android:id/message"))) { + com.codeborne.selenide.Selenide.$(AppiumBy.id("android:id/button1")).click(); + } + } + + private void ensureLoggedIn() { + LoginScreen loginScreen = new LoginScreen(); + if (!loginScreen.isVisible()) { + return; + } + String username = AuthContext.username(); + String password = AuthContext.password(); + boolean allowRegister = AuthContext.allowRegister(); + if (password == null && username != null) { + password = "Admin123"; + } + if (username != null && password != null) { + loginScreen.loginOrRegister(username, password, allowRegister); + } + } + + public void openFirstUser() { + ElementsCollection users = MobileUi.allById("user_item"); + if (users.isEmpty()) { + users = MobileUi.allById("list_item"); + } + users.first().click(); + } + + public boolean openUserByName(String name) { + String pkg = MobileContext.packageName(); + String safe = name.replace("\"", "\\\""); + int maxSwipes = Integer.getInteger("users.search.swipes", 6); + String selector = "new UiScrollable(new UiSelector().scrollable(true))" + + ".setMaxSearchSwipes(" + maxSwipes + ")" + + ".scrollIntoView(new UiSelector().textContains(\"" + safe + "\"))"; + try { + $(AppiumBy.androidUIAutomator(selector)); + String xpath = "//android.view.ViewGroup[(@resource-id='" + pkg + ":id/user_item'" + + " or @resource-id='" + pkg + ":id/list_item') and .//*[contains(@text,\"" + safe + "\")]]"; + SelenideElement row = $(AppiumBy.xpath(xpath)); + if (row.exists()) { + row.click(); + return true; + } + $(AppiumBy.androidUIAutomator(selector)).click(); + return true; + } catch (Throwable ignored) { + return false; + } + } + + public void openUserWithGift(String preferredName) { + if (preferredName != null && !preferredName.isBlank() && Boolean.getBoolean("reservation.fast")) { + if (openUserByName(preferredName) && openFirstWishlistWithGift()) { + return; + } + throw new IllegalStateException("Preferred user not found or has no gifts to reserve."); + } + openFirstUserWithGift(); + } + + public void openFirstWishlist() { + ElementsCollection wishlists = MobileUi.allById("wishlist_item"); + if (wishlists.isEmpty()) { + wishlists = MobileUi.allById("list_item"); + } + wishlists.first().click(); + } + + public void openFirstGift() { + ElementsCollection gifts = MobileUi.allById("gift_item"); + if (gifts.isEmpty()) { + gifts = MobileUi.allById("list_item"); + } + gifts.first().click(); + } + + public boolean toggleReservation() { + SelenideElement reserved = MobileUi.byIdOrAccessibility("reserved"); + boolean before = isChecked(reserved); + reserved.click(); + return isChecked(reserved) != before; + } + + private boolean isChecked(SelenideElement element) { + String checked = element.getAttribute("checked"); + if (checked != null) { + return Boolean.parseBoolean(checked); + } + String selected = element.getAttribute("selected"); + return Boolean.parseBoolean(selected); + } + + private void openFirstUserWithGift() { + int maxPages = Integer.getInteger("users.scan.pages", 1); + for (int page = 0; page < maxPages; page++) { + ElementsCollection users = MobileUi.allById("user_item"); + if (users.isEmpty()) { + users = MobileUi.allById("list_item"); + } + for (int i = 0; i < users.size(); i++) { + users.get(i).click(); + if (openFirstWishlistWithGift()) { + return; + } + returnToUsersList(); + } + String scrollForward = "new UiScrollable(new UiSelector().scrollable(true)).scrollForward()"; + $(AppiumBy.androidUIAutomator(scrollForward)); + } + openFirstUser(); + } + + private boolean hasWishlists() { + return MobileUi.existsById("wishlist_item") + || MobileUi.existsById("list_item"); + } + + private boolean openFirstWishlistWithGift() { + if (!hasWishlists()) { + return false; + } + openFirstWishlist(); + if (hasGifts()) { + return true; + } + MobileUi.back(); + return false; + } + + private boolean hasGifts() { + if (MobileUi.existsById("gift_item")) { + return true; + } + return MobileUi.existsById("list_item"); + } + + private void returnToUsersList() { + for (int i = 0; i < 2; i++) { + if (MobileUi.existsById("users_content") + || MobileUi.existsById("user_item")) { + return; + } + MobileUi.back(); + } + } +} diff --git a/src/test/java/ru/otus/mobile/screens/WishlistsScreen.java b/src/test/java/ru/otus/mobile/screens/WishlistsScreen.java new file mode 100644 index 0000000..e3b5e82 --- /dev/null +++ b/src/test/java/ru/otus/mobile/screens/WishlistsScreen.java @@ -0,0 +1,155 @@ +package ru.otus.mobile.screens; + +import com.codeborne.selenide.ElementsCollection; +import com.codeborne.selenide.SelenideElement; +import io.appium.java_client.AppiumBy; +import org.openqa.selenium.WebElement; +import ru.otus.mobile.config.AuthContext; +import ru.otus.mobile.config.MobileContext; +import ru.otus.mobile.ui.MobileUi; +import ru.otus.mobile.ui.UiWait; + +import static com.codeborne.selenide.Selenide.$; + +public class WishlistsScreen { + + public void open() { + ensureLoggedIn(); + if (MobileUi.existsById("wishlists_content")) { + return; + } + dismissErrorDialogIfPresent(); + if (MobileUi.existsById("mine_menu")) { + MobileUi.byIdOrAccessibility("mine_menu").click(); + } else if (MobileUi.existsById("go_to_wishlists")) { + MobileUi.byIdOrAccessibility("go_to_wishlists").click(); + } + } + + private void dismissErrorDialogIfPresent() { + if (UiWait.exists(AppiumBy.id("android:id/alertTitle")) + || UiWait.exists(AppiumBy.id("android:id/message"))) { + $(AppiumBy.id("android:id/button1")).click(); + } + } + + public void createWishlist(String name) { + if (!MobileUi.existsById("add_button")) { + open(); + } + MobileUi.byIdOrAccessibility("add_button").click(); + typeInto("title_input", name); + MobileUi.byIdOrAccessibility("save_button").click(); + } + + public void editWishlist(String oldName, String newName) { + for (int attempt = 0; attempt < 3; attempt++) { + if (MobileUi.existsById("wishlists_content") + || MobileUi.existsById("wishlist_item") + || MobileUi.existsById("list_item")) { + break; + } + MobileUi.back(); + } + ensureWishlistsList(); + scrollToWishlistTitle(oldName); + for (int attempt = 0; attempt < 2; attempt++) { + editButtonFor(oldName).click(); + break; + } + typeInto("title_input", newName); + MobileUi.byIdOrAccessibility("save_button").click(); + } + + public void assertWishlistPresent(String name) { + scrollToWishlistTitle(name); + } + + private SelenideElement scrollToWishlistTitle(String name) { + String pkg = MobileContext.packageName(); + String safe = name.replace("\"", "\\\""); + String selector = "new UiScrollable(new UiSelector().resourceId(\"" + + pkg + ":id/wishlists\")).scrollIntoView(" + + "new UiSelector().resourceId(\"" + pkg + ":id/title\").textContains(\"" + safe + "\"))"; + try { + return $(AppiumBy.androidUIAutomator(selector)); + } catch (Exception ignored) { + String fallback = "new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(" + + "new UiSelector().textContains(\"" + safe + "\"))"; + return $(AppiumBy.androidUIAutomator(fallback)); + } + } + + private SelenideElement editButtonFor(String name) { + String pkg = MobileContext.packageName(); + String safe = name.replace("\"", "\\\""); + String xpath = "//androidx.recyclerview.widget.RecyclerView[@resource-id='" + pkg + ":id/wishlists']" + + "//android.view.ViewGroup[@resource-id='" + pkg + ":id/wishlist_item'" + + " and .//android.widget.TextView[@resource-id='" + pkg + ":id/title'" + + " and contains(@text,\"" + safe + "\")]]" + + "//android.widget.Button[@resource-id='" + pkg + ":id/edit_button']"; + return $(AppiumBy.xpath(xpath)); + } + + public void openWishlistByName(String name) { + SelenideElement byText = MobileUi.byTextContains(name); + if (byText.exists()) { + byText.click(); + return; + } + openFirstWishlist(); + } + + public void openFirstWishlist() { + ElementsCollection items = MobileUi.allById("wishlist_item"); + if (items.isEmpty()) { + items = MobileUi.allById("list_item"); + } + items.first().click(); + } + + private void typeInto(String id, String value) { + WebElement input = MobileUi.byIdOrAccessibility(id) + .toWebElement(); + input.click(); + input.clear(); + input.sendKeys(value); + } + + private void ensureWishlistsList() { + for (int attempt = 0; attempt < 2; attempt++) { + if (MobileUi.existsById("gifts_content") + || MobileUi.existsById("gift_item")) { + MobileUi.back(); + continue; + } + if (MobileUi.existsById("wishlists_content") + || MobileUi.existsById("wishlist_item") + || MobileUi.existsById("list_item")) { + return; + } + MobileUi.back(); + } + if (MobileUi.existsById("wishlist_item") + || MobileUi.existsById("list_item")) { + return; + } + MobileUi.byIdOrAccessibility("wishlists_content"); + } + + private void ensureLoggedIn() { + LoginScreen loginScreen = new LoginScreen(); + if (!loginScreen.isVisible()) { + return; + } + String username = AuthContext.username(); + String password = AuthContext.password(); + boolean allowRegister = AuthContext.allowRegister(); + if (password == null && username != null) { + password = "Admin123"; + } + if (username != null && password != null) { + loginScreen.loginOrRegister(username, password, allowRegister); + } + } +} diff --git a/src/test/java/ru/otus/mobile/tests/BaseMobileTest.java b/src/test/java/ru/otus/mobile/tests/BaseMobileTest.java new file mode 100644 index 0000000..fd7643d --- /dev/null +++ b/src/test/java/ru/otus/mobile/tests/BaseMobileTest.java @@ -0,0 +1,132 @@ +package ru.otus.mobile.tests; + +import com.codeborne.selenide.Configuration; +import com.codeborne.selenide.WebDriverRunner; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import ru.otus.mobile.db.DbClient; +import ru.otus.mobile.db.DbConfig; +import ru.otus.mobile.config.MobileConfig; +import ru.otus.mobile.config.AuthContext; +import ru.otus.mobile.config.MobileContext; +import ru.otus.mobile.driver.MobileDriverFactory; +import ru.otus.mobile.screens.LoginScreen; +import ru.otus.mobile.ui.MobileUi; +import ru.otus.mobile.util.TestAccount; + +public abstract class BaseMobileTest { + protected MobileConfig config; + + @BeforeEach + void setUp() { + config = MobileConfig.load(); + MobileContext.init(config); + Configuration.timeout = 0; + Configuration.browserSize = null; + ensureCredentials(); + setActiveCredentials(); + resetData(); + WebDriverRunner.setWebDriver(MobileDriverFactory.create(config)); + loginIfNeeded(); + } + + @AfterEach + void tearDown() { + WebDriverRunner.closeWebDriver(); + } + + protected void loginIfNeeded() { + LoginScreen loginScreen = new LoginScreen(); + if (loginScreen.isVisible()) { + loginScreen.loginOrRegister(accountUsername(), accountPassword(), allowRegister()); + return; + } + if (MobileUi.existsById("wishlists_content") + || MobileUi.existsById("users_content")) { + return; + } + throw new IllegalStateException("App did not reach login or main screen."); + } + + protected abstract TestAccount account(); + + private void resetData() { + DbConfig dbConfig = DbConfig.load(); + String sql = account().resetSql() != null ? account().resetSql() : dbConfig.resetSql(); + DbClient client = new DbClient(dbConfig); + if (account() == TestAccount.RESERVATION) { + client.resetReservationData(accountUsername(), reservationOwnerUsername(), sql); + } else { + client.resetUserData(accountUsername(), sql); + } + } + + private String accountUsername() { + String accountKey = account().name().toLowerCase(); + String prop = System.getProperty("login.username." + accountKey); + if (prop != null && !prop.isBlank()) { + return prop; + } + String env = System.getenv("LOGIN_USERNAME_" + accountKey.toUpperCase()); + if (env != null && !env.isBlank()) { + return env; + } + return account().username(); + } + + private String accountPassword() { + String accountKey = account().name().toLowerCase(); + String prop = System.getProperty("login.password." + accountKey); + if (prop != null && !prop.isBlank()) { + return prop; + } + String env = System.getenv("LOGIN_PASSWORD_" + accountKey.toUpperCase()); + if (env != null && !env.isBlank()) { + return env; + } + String common = System.getProperty("login.password"); + if (common != null && !common.isBlank()) { + return common; + } + return "Admin123"; + } + + private boolean allowRegister() { + return System.getProperty("login.auto." + account().name().toLowerCase()) != null; + } + + private void ensureCredentials() { + String accountKey = account().name().toLowerCase(); + String prop = System.getProperty("login.username." + accountKey); + String env = System.getenv("LOGIN_USERNAME_" + accountKey.toUpperCase()); + if ((prop == null || prop.isBlank()) && (env == null || env.isBlank())) { + String generated = "auto_" + accountKey + "_" + System.currentTimeMillis(); + System.setProperty("login.username." + accountKey, generated); + System.setProperty("login.password." + accountKey, "Admin123"); + System.setProperty("login.auto." + accountKey, "true"); + } + if (account() == TestAccount.RESERVATION && System.getProperty("reservation.owner") == null) { + System.setProperty("reservation.owner", "user4us"); + } + } + + private void setActiveCredentials() { + System.setProperty("login.account.active", account().name().toLowerCase()); + System.setProperty("login.username.active", accountUsername()); + System.setProperty("login.password.active", accountPassword()); + System.setProperty("login.auto.active", Boolean.toString(allowRegister())); + AuthContext.init(accountUsername(), accountPassword(), allowRegister()); + } + + private String reservationOwnerUsername() { + String prop = System.getProperty("reservation.owner"); + if (prop != null && !prop.isBlank()) { + return prop; + } + String env = System.getenv("RESERVATION_OWNER"); + if (env != null && !env.isBlank()) { + return env; + } + return "user4us"; + } +} diff --git a/src/test/java/ru/otus/mobile/tests/GiftsTest.java b/src/test/java/ru/otus/mobile/tests/GiftsTest.java new file mode 100644 index 0000000..4029f78 --- /dev/null +++ b/src/test/java/ru/otus/mobile/tests/GiftsTest.java @@ -0,0 +1,34 @@ +package ru.otus.mobile.tests; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import ru.otus.mobile.screens.GiftsScreen; +import ru.otus.mobile.screens.WishlistsScreen; +import ru.otus.mobile.util.TestAccount; +import ru.otus.mobile.util.TestData; + +public class GiftsTest extends BaseMobileTest { + @Test + @DisplayName("Создание и редактирование подарка") + void createAndEditGift() { + WishlistsScreen wishlists = new WishlistsScreen(); + GiftsScreen gifts = new GiftsScreen(); + String wishlistName = TestData.uniqueName("Wishlist"); + String name = TestData.uniqueName("Gift"); + String updated = name + " updated"; + + wishlists.open(); + wishlists.createWishlist(wishlistName); + wishlists.openWishlistByName(wishlistName); + gifts.open(); + gifts.createGift(name); + gifts.assertGiftPresent(name); + gifts.editGift(name, updated); + gifts.assertGiftPresent(updated); + } + + @Override + protected TestAccount account() { + return TestAccount.GIFTS; + } +} diff --git a/src/test/java/ru/otus/mobile/tests/ReservationTest.java b/src/test/java/ru/otus/mobile/tests/ReservationTest.java new file mode 100644 index 0000000..b0d0815 --- /dev/null +++ b/src/test/java/ru/otus/mobile/tests/ReservationTest.java @@ -0,0 +1,27 @@ +package ru.otus.mobile.tests; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import ru.otus.mobile.screens.UsersScreen; +import ru.otus.mobile.util.TestAccount; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ReservationTest extends BaseMobileTest { + @Test + @DisplayName("Изменение статуса резервирования подарка другого пользователя") + void changeReservationStatus() { + UsersScreen users = new UsersScreen(); + users.open(); + String owner = System.getProperty("reservation.owner", "user4us"); + users.openUserWithGift(owner); + users.openFirstGift(); + boolean changed = users.toggleReservation(); + assertTrue(changed, "Reservation status was not changed"); + } + + @Override + protected TestAccount account() { + return TestAccount.RESERVATION; + } +} diff --git a/src/test/java/ru/otus/mobile/tests/WishlistsTest.java b/src/test/java/ru/otus/mobile/tests/WishlistsTest.java new file mode 100644 index 0000000..f5b9c4f --- /dev/null +++ b/src/test/java/ru/otus/mobile/tests/WishlistsTest.java @@ -0,0 +1,28 @@ +package ru.otus.mobile.tests; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import ru.otus.mobile.screens.WishlistsScreen; +import ru.otus.mobile.util.TestAccount; +import ru.otus.mobile.util.TestData; + +public class WishlistsTest extends BaseMobileTest { + @Test + @DisplayName("Создание и редактирование списка желаний") + void createAndEditWishlist() { + WishlistsScreen wishlists = new WishlistsScreen(); + String name = TestData.uniqueName("Wishlist"); + String updated = name + " updated"; + + wishlists.open(); + wishlists.createWishlist(name); + wishlists.assertWishlistPresent(name); + wishlists.editWishlist(name, updated); + wishlists.assertWishlistPresent(updated); + } + + @Override + protected TestAccount account() { + return TestAccount.WISHLISTS; + } +} diff --git a/src/test/java/ru/otus/mobile/ui/MobileSelectors.java b/src/test/java/ru/otus/mobile/ui/MobileSelectors.java new file mode 100644 index 0000000..ad182a4 --- /dev/null +++ b/src/test/java/ru/otus/mobile/ui/MobileSelectors.java @@ -0,0 +1,23 @@ +package ru.otus.mobile.ui; + +import io.appium.java_client.AppiumBy; +import org.openqa.selenium.By; +import ru.otus.mobile.config.MobileContext; + +public final class MobileSelectors { + private MobileSelectors() { + } + + public static By id(String id) { + return AppiumBy.id(MobileContext.packageName() + ":id/" + id); + } + + public static By accessibilityId(String id) { + return AppiumBy.accessibilityId(id); + } + + public static By textContains(String text) { + String safeText = text.replace("\"", "\\\""); + return AppiumBy.androidUIAutomator("new UiSelector().textContains(\"" + safeText + "\")"); + } +} diff --git a/src/test/java/ru/otus/mobile/ui/MobileUi.java b/src/test/java/ru/otus/mobile/ui/MobileUi.java new file mode 100644 index 0000000..e49aabc --- /dev/null +++ b/src/test/java/ru/otus/mobile/ui/MobileUi.java @@ -0,0 +1,96 @@ +package ru.otus.mobile.ui; + +import com.codeborne.selenide.ElementsCollection; +import com.codeborne.selenide.SelenideElement; +import com.codeborne.selenide.WebDriverRunner; +import io.appium.java_client.AppiumBy; +import io.appium.java_client.android.AndroidDriver; +import io.appium.java_client.android.nativekey.AndroidKey; +import io.appium.java_client.android.nativekey.KeyEvent; +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.remote.RemoteWebElement; + +import java.util.HashMap; +import java.util.Map; + +import static com.codeborne.selenide.Selenide.$; +import static com.codeborne.selenide.Selenide.$$; + +public final class MobileUi { + private MobileUi() { + } + + public static SelenideElement byId(String id) { + return $(MobileSelectors.id(id)); + } + + public static ElementsCollection allById(String id) { + return $$(MobileSelectors.id(id)); + } + + public static SelenideElement byIdOrAccessibility(String id) { + if (UiWait.exists(MobileSelectors.id(id))) { + return $(MobileSelectors.id(id)); + } + if (UiWait.exists(MobileSelectors.accessibilityId(id))) { + return $(MobileSelectors.accessibilityId(id)); + } + return $(MobileSelectors.id(id)); + } + + public static SelenideElement byTextContains(String text) { + return $(MobileSelectors.textContains(text)); + } + + public static boolean existsById(String id) { + return UiWait.exists(MobileSelectors.id(id)); + } + + public static SelenideElement byIdOrText(String id, String text) { + ElementsCollection byId = allById(id); + if (!byId.isEmpty()) { + return byId.first(); + } + return $(MobileSelectors.textContains(text)); + } + + public static SelenideElement byIdOrTextOrAccessibility(String id, String text) { + ElementsCollection byId = allById(id); + if (!byId.isEmpty()) { + return byId.first(); + } + ElementsCollection byA11y = $$(AppiumBy.accessibilityId(id)); + if (!byA11y.isEmpty()) { + return byA11y.first(); + } + return $(MobileSelectors.textContains(text)); + } + + public static void back() { + AndroidDriver driver = (AndroidDriver) WebDriverRunner.getWebDriver(); + driver.pressKey(new KeyEvent(AndroidKey.BACK)); + } + + public static void longPress(SelenideElement element) { + String elementId = ((RemoteWebElement) element.toWebElement()).getId(); + Map params = new HashMap<>(); + params.put("elementId", elementId); + params.put("duration", 1000); + ((JavascriptExecutor) WebDriverRunner.getWebDriver()) + .executeScript("mobile: longClickGesture", params); + } + + public static void hideKeyboard() { + AndroidDriver driver = (AndroidDriver) WebDriverRunner.getWebDriver(); + try { + driver.hideKeyboard(); + } catch (Exception ignored) { + } + } + + public static void restartApp(String appPackage) { + AndroidDriver driver = (AndroidDriver) WebDriverRunner.getWebDriver(); + driver.terminateApp(appPackage); + driver.activateApp(appPackage); + } +} diff --git a/src/test/java/ru/otus/mobile/ui/UiWait.java b/src/test/java/ru/otus/mobile/ui/UiWait.java new file mode 100644 index 0000000..7df55ea --- /dev/null +++ b/src/test/java/ru/otus/mobile/ui/UiWait.java @@ -0,0 +1,14 @@ +package ru.otus.mobile.ui; + +import org.openqa.selenium.By; + +import static com.codeborne.selenide.Selenide.$; + +public final class UiWait { + private UiWait() { + } + + public static boolean exists(By locator) { + return $(locator).exists(); + } +} diff --git a/src/test/java/ru/otus/mobile/util/TestAccount.java b/src/test/java/ru/otus/mobile/util/TestAccount.java new file mode 100644 index 0000000..90bd12d --- /dev/null +++ b/src/test/java/ru/otus/mobile/util/TestAccount.java @@ -0,0 +1,77 @@ +package ru.otus.mobile.util; + +public enum TestAccount { + WISHLISTS("hw7_wishlists_user", null), + GIFTS("hw7_gifts_user", null), + RESERVATION("hw7_reservation_user", """ + WITH owner_user AS ( + SELECT id FROM users WHERE username = ? + ), + owner_insert AS ( + INSERT INTO users (id, email, password, username) + SELECT gen_random_uuid(), ?, ?, ? + WHERE NOT EXISTS (SELECT 1 FROM owner_user) + RETURNING id + ), + owner AS ( + SELECT id FROM owner_user + UNION ALL + SELECT id FROM owner_insert + ), + target_user_existing AS ( + SELECT id FROM users WHERE username = ? + ), + target_user_insert AS ( + INSERT INTO users (id, email, password, username) + SELECT gen_random_uuid(), ?, ?, ? + WHERE NOT EXISTS (SELECT 1 FROM target_user_existing) + RETURNING id + ), + target_user AS ( + SELECT id FROM target_user_existing + UNION ALL + SELECT id FROM target_user_insert + ), + del_owner_gifts AS ( + DELETE FROM gifts + WHERE wish_id IN (SELECT id FROM wishlists WHERE user_id = (SELECT id FROM owner)) + ), + del_owner_wishlists AS ( + DELETE FROM wishlists WHERE user_id = (SELECT id FROM owner) + ), + del_user_gifts AS ( + DELETE FROM gifts + WHERE wish_id IN (SELECT id FROM wishlists WHERE user_id IN (SELECT id FROM target_user)) + ), + del_user_wishlists AS ( + DELETE FROM wishlists WHERE user_id IN (SELECT id FROM target_user) + ), + owner_wishlist AS ( + INSERT INTO wishlists (id, title, description, user_id) + SELECT gen_random_uuid(), 'Owner Wishlist', 'Auto', (SELECT id FROM owner) + WHERE EXISTS (SELECT 1 FROM owner) + RETURNING id + ), + owner_gift AS ( + INSERT INTO gifts (id, name, description, price, is_reserved, store_url, image_url, wish_id) + SELECT gen_random_uuid(), 'Owner Gift', 'Auto', 100, false, NULL, NULL, (SELECT id FROM owner_wishlist) + ) + SELECT 1; + """); + + private final String username; + private final String resetSql; + + TestAccount(String username, String resetSql) { + this.username = username; + this.resetSql = resetSql; + } + + public String username() { + return username; + } + + public String resetSql() { + return resetSql; + } +} diff --git a/src/test/java/ru/otus/mobile/util/TestData.java b/src/test/java/ru/otus/mobile/util/TestData.java new file mode 100644 index 0000000..304e631 --- /dev/null +++ b/src/test/java/ru/otus/mobile/util/TestData.java @@ -0,0 +1,19 @@ +package ru.otus.mobile.util; + +import java.security.SecureRandom; +import java.time.Instant; + +public final class TestData { + private static final SecureRandom RANDOM = new SecureRandom(); + + private TestData() { + } + + public static String uniqueName(String prefix) { + long ts = Instant.now().getEpochSecond(); + int rnd = RANDOM.nextInt(36 * 36); + String tsPart = Long.toString(ts, 36); + String rndPart = String.format("%2s", Integer.toString(rnd, 36)).replace(' ', '0'); + return prefix + "-" + tsPart + rndPart; + } +} diff --git a/wishlist-349317-5fd795.apk b/wishlist-349317-5fd795.apk new file mode 100644 index 0000000..a34adf4 Binary files /dev/null and b/wishlist-349317-5fd795.apk differ