Refactor hw7 mobile tests to meet course requirements

This commit is contained in:
2026-04-09 23:03:38 +03:00
parent 6ca010ed17
commit 8857b4feff
54 changed files with 1337 additions and 1805 deletions
+5
View File
@@ -3,4 +3,9 @@ build/
.idea/
*.iml
.DS_Store
allure-results/
logcat.txt
apk/
jobs/
scripts/
wiremock/__files/
+49 -118
View File
@@ -1,137 +1,68 @@
# OTUS Homework 7: Mobile (Selenide + Appium)
# OTUS Homework 7: Mobile Testing
Автотесты мобильного приложения Wishlist через Selenide + Appium и инфраструктура эмулятора через Docker Compose.
Проект содержит мобильные UI-тесты для приложения Wishlist на базе `selenide-appium`.
## Что реализовано
- 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)
## Архитектура
- `Guice` для DI.
- `JUnit 5 Extension` вместо базового тестового класса.
- `AbsPageObject` -> `AbsBasePage` / `BaseMobileComponent`.
- `BlockingQueue` для распределения тестов по эмуляторам.
- код инфраструктуры и page object находится в `src/main/java`;
- в `src/test/java` находятся только тестовые классы.
## Структура проекта
- `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.yml` поднимает:
- `wiremock` для раздачи `wishlist.apk`;
- `android-emulator` с Appium и VNC.
## Инфраструктура (Docker Compose)
Эмулятор и Appium поднимаются одной командой:
```bash
docker compose up -d
```
Приложение не маунтится в эмулятор и не ставится через ADB.
APK скачивается Appium по capability `app`.
Источник APK для Wiremock: файл `wishlist-349317-5fd795.apk` в корне проекта.
Эмулятор запускается без `privileged`, доступ к аппаратной виртуализации передается через `/dev/kvm`.
Порты:
- Appium: `http://localhost:4723`
- noVNC: `http://localhost:6080`
## Тестовые аккаунты
Тесты используют заранее созданные аккаунты:
- `user1us / user1us`
- `user2us / user2us`
- `user3us / user3us`
- `user4us / user4us`
APK берется из файла `./wishlist-349317-5fd795.apk`, копируется в `./apk/wishlist.apk` и монтируется в контейнер по пути `/apk/wishlist.apk`.
После старта контейнеров APK автоматически устанавливается в эмулятор и приложение стартует.
`user4us` используется как владелец подарка в тесте резервирования.
## Подготовка данных через БД
Для повторяемости тестов перед каждым тестом выполняется SQL‑сброс данных аккаунта.
Данные доступа к БД **не хранятся в репозитории** — нужно передать через env или `-D`.
Аккаунты можно создать заранее (регистрация через приложение) или доверить это автотестам.
Если логин/пароль не переданы через параметры запуска, тест сгенерирует логины, создаст пользователей через reset‑SQL в БД (если их нет), затем выполнит логин. Регистрация через приложение используется как fallback, если вход не удался.
## Подготовка
Нужно задать доступ к БД, иначе `mvn test` завершится ошибкой:
Переменные/свойства:
- `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://<host>:5432/<db>"
$env:DB_USER = "<user>"
$env:DB_PASSWORD = "<password>"
$env:DB_RESET_SQL = "<one-statement reset sql with ? placeholders>"
```
## Как запускать
### Полный прогон (обязательная последовательность)
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) Запустить все тесты (логины/пароли можно не задавать):
## Запуск
1. Поднять окружение:
```bash
docker compose up -d
```
2. Дождаться статуса `healthy` у `wiremock` и `android-emulator`:
```bash
docker compose ps
```
Тесты не ждут загрузку эмулятора сами. Готовность окружения проверяется на уровне Docker Compose.
3. Запустить тесты:
```bash
mvn test
```
Если логины не заданы, они будут сгенерированы автоматически, пользователь будет создан через БД (если отсутствует) и выполнится вход.
Регистрация через приложение используется только как fallback, если вход не удался.
Опционально: задать логины/пароли тестовых пользователей (если зарегистрированы вручную):
```bash
mvn "-Dlogin.username.wishlists=<user1>" "-Dlogin.password.wishlists=<pass1>" \
"-Dlogin.username.gifts=<user2>" "-Dlogin.password.gifts=<pass2>" \
"-Dlogin.username.reservation=<user3>" "-Dlogin.password.reservation=<pass3>" \
"-Dreservation.owner=<user4>" \
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://<host>:5432/<db> -Ddb.user=<user> -Ddb.password=<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.<account>` — логин для конкретного теста (например `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` в корне проекта.
## Логи
После выполнения тестов logcat сохраняется в файл `logcat.txt` в корне проекта через Selenium/Appium logs API.
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0"?>
<!DOCTYPE module PUBLIC
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
"https://checkstyle.org/dtds/configuration_1_3.dtd">
<module name="Checker">
<property name="charset" value="UTF-8"/>
<property name="severity" value="error"/>
<module name="FileTabCharacter"/>
<module name="LineLength">
<property name="max" value="120"/>
</module>
<module name="TreeWalker">
<module name="TypeName"/>
<module name="MethodName"/>
<module name="ParameterName"/>
<module name="LocalVariableName"/>
<module name="MemberName"/>
<module name="AvoidStarImport"/>
<module name="UnusedImports"/>
<module name="RedundantImport"/>
<module name="NeedBraces"/>
<module name="WhitespaceAround"/>
<module name="WhitespaceAfter"/>
<module name="EmptyBlock"/>
</module>
</module>
+21 -53
View File
@@ -1,67 +1,35 @@
services:
apk-downloader:
image: curlimages/curl:8.5.0
user: "0: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
wiremock:
image: wiremock/wiremock:3.9.1
volumes:
- ./apk:/apk
- ./wishlist-349317-5fd795.apk:/apk-source/app.apk:ro
- ./wiremock:/home/wiremock
- ./wishlist-349317-5fd795.apk:/home/wiremock/__files/wishlist.apk:ro
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/__admin/health | grep -q 'healthy'"]
interval: 10s
timeout: 5s
retries: 12
android-emulator:
image: budtmo/docker-android:emulator_13.0
privileged: true
depends_on:
apk-downloader:
condition: service_completed_successfully
- wiremock
devices:
- /dev/kvm:/dev/kvm
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
- AUTO_GRANT_PERMISSIONS=true
- EMULATOR_PARAMS=-no-window -no-audio -gpu swiftshader_indirect -no-snapshot -no-boot-anim
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
healthcheck:
test: ["CMD-SHELL", "[ \"$(cat /home/androidusr/device_status 2>/dev/null)\" = \"READY\" ]"]
interval: 15s
timeout: 5s
retries: 40
start_period: 30s
+80 -13
View File
@@ -1,7 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>ru.otus</groupId>
@@ -13,12 +14,18 @@
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<selenide.version>7.3.1</selenide.version>
<appium.version>9.3.0</appium.version>
<selenide.appium.version>7.3.1</selenide.appium.version>
<junit.version>5.10.2</junit.version>
<apkparser.version>2.6.10</apkparser.version>
<slf4j.version>2.0.13</slf4j.version>
<selenium.version>4.25.0</selenium.version>
<logback.version>1.5.6</logback.version>
<selenium.version>4.20.0</selenium.version>
<allure.version>2.29.1</allure.version>
<guice.version>7.0.0</guice.version>
<maven.compiler.plugin.version>3.13.0</maven.compiler.plugin.version>
<surefire.version>3.2.5</surefire.version>
<checkstyle.plugin.version>3.6.0</checkstyle.plugin.version>
<spotbugs.plugin.version>4.9.2.0</spotbugs.plugin.version>
<spotbugs.version>4.9.3</spotbugs.version>
</properties>
<dependencyManagement>
@@ -40,18 +47,23 @@
<version>${selenide.version}</version>
</dependency>
<dependency>
<groupId>io.appium</groupId>
<artifactId>java-client</artifactId>
<version>${appium.version}</version>
<groupId>com.codeborne</groupId>
<artifactId>selenide-appium</artifactId>
<version>${selenide.appium.version}</version>
</dependency>
<dependency>
<groupId>net.dongliu</groupId>
<artifactId>apk-parser</artifactId>
<version>${apkparser.version}</version>
<groupId>com.google.inject</groupId>
<artifactId>guice</artifactId>
<version>${guice.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
@@ -63,8 +75,13 @@
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<artifactId>slf4j-api</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>${logback.version}</version>
<scope>test</scope>
</dependency>
<dependency>
@@ -77,14 +94,64 @@
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${maven.compiler.plugin.version}</version>
<configuration>
<release>${maven.compiler.source}</release>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<version>${surefire.version}</version>
<configuration>
<useModulePath>false</useModulePath>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>${checkstyle.plugin.version}</version>
<executions>
<execution>
<id>checkstyle-validation</id>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
<configuration>
<configLocation>checkstyle.xml</configLocation>
<consoleOutput>true</consoleOutput>
<failsOnError>true</failsOnError>
</configuration>
</plugin>
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
<version>${spotbugs.plugin.version}</version>
<dependencies>
<dependency>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs</artifactId>
<version>${spotbugs.version}</version>
</dependency>
</dependencies>
<executions>
<execution>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
<configuration>
<excludeFilterFile>spotbugs-exclude.xml</excludeFilterFile>
</configuration>
</plugin>
</plugins>
</build>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<FindBugsFilter>
<Match>
<Source name="~.*src/test/java/.*"/>
</Match>
<Match>
<Class name="ru.otus.mobile.config.TestContext"/>
<Bug pattern="EI_EXPOSE_REP,EI_EXPOSE_REP2"/>
</Match>
<Match>
<Class name="ru.otus.mobile.driver.MobileSession"/>
<Bug pattern="EI_EXPOSE_REP2"/>
</Match>
<Match>
<Class name="ru.otus.mobile.driver.MobileSessionFactory"/>
<Bug pattern="EI_EXPOSE_REP2"/>
</Match>
</FindBugsFilter>
@@ -0,0 +1,14 @@
package ru.otus.mobile.annotations;
import ru.otus.mobile.config.TestAccount;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface MobileUser {
TestAccount value();
}
@@ -0,0 +1,25 @@
package ru.otus.mobile.component;
import com.google.inject.Inject;
import ru.otus.mobile.config.MobileConfig;
public final class AlertDialogComponent extends BaseMobileComponent {
@Inject
public AlertDialogComponent(MobileConfig config) {
super(config);
}
public void acceptIfVisible() {
if (exists("android:id/button1")) {
tap("android:id/button1");
return;
}
if (byText("OK").exists()) {
byText("OK").click();
return;
}
if (byText("ОК").exists()) {
byText("ОК").click();
}
}
}
@@ -0,0 +1,10 @@
package ru.otus.mobile.component;
import ru.otus.mobile.config.MobileConfig;
import ru.otus.mobile.page.AbsPageObject;
public abstract class BaseMobileComponent extends AbsPageObject {
protected BaseMobileComponent(MobileConfig config) {
super(config);
}
}
@@ -0,0 +1,19 @@
package ru.otus.mobile.component;
import com.google.inject.Inject;
import ru.otus.mobile.config.MobileConfig;
public final class BottomNavigationComponent extends BaseMobileComponent {
@Inject
public BottomNavigationComponent(MobileConfig config) {
super(config);
}
public void openWishlists() {
tap("mine_menu");
}
public void openUsers() {
tap("users_menu");
}
}
@@ -0,0 +1,17 @@
package ru.otus.mobile.component;
import com.google.inject.Inject;
import ru.otus.mobile.config.MobileConfig;
public final class GiftFormComponent extends BaseMobileComponent {
@Inject
public GiftFormComponent(MobileConfig config) {
super(config);
}
public void save(String name, String price) {
type("name_input", name);
type("price_input", price);
tap("save_button");
}
}
@@ -0,0 +1,16 @@
package ru.otus.mobile.component;
import com.google.inject.Inject;
import ru.otus.mobile.config.MobileConfig;
public final class WishlistFormComponent extends BaseMobileComponent {
@Inject
public WishlistFormComponent(MobileConfig config) {
super(config);
}
public void save(String title) {
type("title_input", title);
tap("save_button");
}
}
@@ -0,0 +1,61 @@
package ru.otus.mobile.config;
import java.util.ArrayList;
import java.util.List;
public final class MobileConfig {
private final String appPackage;
private final String appUrl;
private final String reservationOwnerUsername;
private final List<Emulator> emulators;
public MobileConfig(
String appPackage,
String appUrl,
String reservationOwnerUsername,
List<Emulator> emulators
) {
this.appPackage = appPackage;
this.appUrl = appUrl;
this.reservationOwnerUsername = reservationOwnerUsername;
this.emulators = List.copyOf(emulators);
}
public String appPackage() {
return appPackage;
}
public String appUrl() {
return appUrl;
}
public String reservationOwnerUsername() {
return reservationOwnerUsername;
}
public List<Emulator> emulators() {
return emulators;
}
public record Emulator(String id, String appiumUrl, String deviceName, String appUrl) {
public static List<Emulator> parse(String rawValue, String defaultAppUrl) {
List<Emulator> emulators = new ArrayList<>();
for (String entry : rawValue.split(",")) {
String trimmed = entry.trim();
if (trimmed.isEmpty()) {
continue;
}
String[] parts = trimmed.split("\\|");
String id = parts.length > 0 ? parts[0].trim() : "emulator-1";
String appiumUrl = parts.length > 1 ? parts[1].trim() : "http://localhost:4723";
String deviceName = parts.length > 2 ? parts[2].trim() : "Android Emulator";
String emulatorAppUrl = parts.length > 3 ? parts[3].trim() : defaultAppUrl;
emulators.add(new Emulator(id, appiumUrl, deviceName, emulatorAppUrl));
}
if (emulators.isEmpty()) {
emulators.add(new Emulator("emulator-1", "http://localhost:4723", "Android Emulator", defaultAppUrl));
}
return emulators;
}
}
}
@@ -0,0 +1,22 @@
package ru.otus.mobile.config;
import java.nio.file.Path;
import java.nio.file.Paths;
public final class ProjectPaths {
private final Path projectRoot;
private final Path logcatFile;
public ProjectPaths() {
this.projectRoot = Paths.get("").toAbsolutePath().normalize();
this.logcatFile = projectRoot.resolve("logcat.txt");
}
public Path projectRoot() {
return projectRoot;
}
public Path logcatFile() {
return logcatFile;
}
}
@@ -0,0 +1,83 @@
package ru.otus.mobile.config;
public enum TestAccount {
WISHLISTS("user1us", "user1us", """
WITH target_user AS (
SELECT id FROM users WHERE username = ?
),
delete_gifts AS (
DELETE FROM gifts
WHERE wish_id IN (SELECT id FROM wishlists WHERE user_id IN (SELECT id FROM target_user))
),
delete_wishlists AS (
DELETE FROM wishlists WHERE user_id IN (SELECT id FROM target_user)
)
SELECT 1;
"""),
GIFTS("user2us", "user2us", """
WITH target_user AS (
SELECT id FROM users WHERE username = ?
),
delete_gifts AS (
DELETE FROM gifts
WHERE wish_id IN (SELECT id FROM wishlists WHERE user_id IN (SELECT id FROM target_user))
),
delete_wishlists AS (
DELETE FROM wishlists WHERE user_id IN (SELECT id FROM target_user)
)
SELECT 1;
"""),
RESERVATION("user3us", "user3us", """
WITH owner_user AS (
SELECT id FROM users WHERE username = ?
),
target_user AS (
SELECT id FROM users WHERE username = ?
),
delete_owner_gifts AS (
DELETE FROM gifts
WHERE wish_id IN (SELECT id FROM wishlists WHERE user_id IN (SELECT id FROM owner_user))
),
delete_owner_wishlists AS (
DELETE FROM wishlists WHERE user_id IN (SELECT id FROM owner_user)
),
delete_target_gifts AS (
DELETE FROM gifts
WHERE wish_id IN (SELECT id FROM wishlists WHERE user_id IN (SELECT id FROM target_user))
),
delete_target_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', 'Prepared for reservation test', id
FROM owner_user
RETURNING id
)
INSERT INTO gifts (id, name, description, price, is_reserved, store_url, image_url, wish_id)
SELECT gen_random_uuid(), 'Owner Gift', 'Prepared for reservation test', 100, false, NULL, NULL, id
FROM owner_wishlist;
""");
private final String username;
private final String password;
private final String resetSql;
TestAccount(String username, String password, String resetSql) {
this.username = username;
this.password = password;
this.resetSql = resetSql;
}
public String username() {
return username;
}
public String password() {
return password;
}
public String resetSql() {
return resetSql;
}
}
@@ -0,0 +1,33 @@
package ru.otus.mobile.config;
import io.appium.java_client.android.AndroidDriver;
public final class TestContext {
private final TestAccount account;
private final MobileConfig.Emulator emulator;
private final AndroidDriver driver;
private final String testName;
public TestContext(TestAccount account, MobileConfig.Emulator emulator, AndroidDriver driver, String testName) {
this.account = account;
this.emulator = emulator;
this.driver = driver;
this.testName = testName;
}
public TestAccount account() {
return account;
}
public MobileConfig.Emulator emulator() {
return emulator;
}
public AndroidDriver driver() {
return driver;
}
public String testName() {
return testName;
}
}
@@ -0,0 +1,36 @@
package ru.otus.mobile.db;
import com.google.inject.Inject;
import ru.otus.mobile.config.MobileConfig;
import ru.otus.mobile.config.TestAccount;
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 final MobileConfig mobileConfig;
@Inject
public DbClient(DbConfig config, MobileConfig mobileConfig) {
this.config = config;
this.mobileConfig = mobileConfig;
}
public void resetData(TestAccount account) {
try (Connection connection = DriverManager.getConnection(config.url(), config.user(), config.password());
PreparedStatement statement = connection.prepareStatement(account.resetSql())) {
if (account == TestAccount.RESERVATION) {
statement.setString(1, mobileConfig.reservationOwnerUsername());
statement.setString(2, account.username());
} else {
statement.setString(1, account.username());
}
statement.execute();
} catch (SQLException e) {
throw new IllegalStateException("Failed to prepare test data for account " + account.name(), e);
}
}
}
@@ -0,0 +1,25 @@
package ru.otus.mobile.db;
public final class DbConfig {
private final String url;
private final String user;
private final String password;
public DbConfig(String url, String user, String password) {
this.url = url;
this.user = user;
this.password = password;
}
public String url() {
return url;
}
public String user() {
return user;
}
public String password() {
return password;
}
}
@@ -0,0 +1,29 @@
package ru.otus.mobile.driver;
import ru.otus.mobile.config.MobileConfig;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public final class EmulatorQueue {
private final BlockingQueue<MobileConfig.Emulator> queue;
public EmulatorQueue(MobileConfig config) {
this.queue = new LinkedBlockingQueue<>(config.emulators());
}
public MobileConfig.Emulator acquire() {
try {
return queue.take();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while waiting for a free emulator.", e);
}
}
public void release(MobileConfig.Emulator emulator) {
if (!queue.offer(emulator)) {
throw new IllegalStateException("Failed to return emulator back to the queue: " + emulator.id());
}
}
}
@@ -0,0 +1,64 @@
package ru.otus.mobile.driver;
import io.appium.java_client.android.AndroidDriver;
import org.openqa.selenium.logging.LogEntries;
import org.openqa.selenium.logging.LogEntry;
import ru.otus.mobile.config.ProjectPaths;
import ru.otus.mobile.config.TestContext;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
public final class LogcatCollector {
private final ProjectPaths projectPaths;
public LogcatCollector(ProjectPaths projectPaths) {
this.projectPaths = projectPaths;
}
public void save(TestContext context) {
AndroidDriver driver = context.driver();
if (driver == null) {
return;
}
try {
LogEntries entries = driver.manage().logs().get("logcat");
StringBuilder buffer = new StringBuilder();
buffer.append("=== ").append(context.testName())
.append(" / ").append(context.emulator().id())
.append(" / ").append(Instant.now())
.append(System.lineSeparator());
for (LogEntry entry : entries) {
buffer.append(entry.getTimestamp())
.append(' ')
.append(entry.getLevel())
.append(' ')
.append(entry.getMessage())
.append(System.lineSeparator());
}
Files.writeString(
projectPaths.logcatFile(),
buffer.append(System.lineSeparator()).toString(),
StandardOpenOption.CREATE,
StandardOpenOption.APPEND
);
} catch (Exception e) {
writeFallback(context.testName(), e);
}
}
private void writeFallback(String testName, Exception error) {
try {
Files.writeString(
projectPaths.logcatFile(),
"Failed to collect logcat for " + testName + ": " + error.getMessage() + System.lineSeparator(),
StandardOpenOption.CREATE,
StandardOpenOption.APPEND
);
} catch (IOException ignored) {
// no-op
}
}
}
@@ -0,0 +1,29 @@
package ru.otus.mobile.driver;
import io.appium.java_client.android.AndroidDriver;
import io.appium.java_client.android.options.UiAutomator2Options;
import ru.otus.mobile.config.MobileConfig;
import java.net.MalformedURLException;
import java.net.URI;
public final class MobileDriverFactory {
private final MobileConfig config;
public MobileDriverFactory(MobileConfig config) {
this.config = config;
}
public AndroidDriver create(MobileConfig.Emulator emulator) {
UiAutomator2Options options = new UiAutomator2Options()
.setPlatformName("Android")
.setAutomationName("UiAutomator2")
.setDeviceName(emulator.deviceName())
.setApp(emulator.appUrl());
try {
return new AndroidDriver(URI.create(emulator.appiumUrl()).toURL(), options);
} catch (MalformedURLException e) {
throw new IllegalStateException("Invalid Appium URL: " + emulator.appiumUrl(), e);
}
}
}
@@ -0,0 +1,46 @@
package ru.otus.mobile.driver;
import com.codeborne.selenide.WebDriverRunner;
import com.google.inject.Injector;
import io.appium.java_client.android.AndroidDriver;
import ru.otus.mobile.config.TestContext;
public final class MobileSession {
private final TestContext context;
private final Injector injector;
private final EmulatorQueue emulatorQueue;
private final LogcatCollector logcatCollector;
public MobileSession(
TestContext context,
Injector injector,
EmulatorQueue emulatorQueue,
LogcatCollector logcatCollector
) {
this.context = context;
this.injector = injector;
this.emulatorQueue = emulatorQueue;
this.logcatCollector = logcatCollector;
}
public void activate() {
WebDriverRunner.setWebDriver(context.driver());
}
public void injectMembers(Object target) {
injector.injectMembers(target);
}
public void close() {
logcatCollector.save(context);
AndroidDriver driver = context.driver();
try {
if (driver != null) {
driver.quit();
}
} finally {
WebDriverRunner.closeWebDriver();
emulatorQueue.release(context.emulator());
}
}
}
@@ -0,0 +1,37 @@
package ru.otus.mobile.driver;
import com.google.inject.Inject;
import com.google.inject.Injector;
import io.appium.java_client.android.AndroidDriver;
import ru.otus.mobile.config.MobileConfig;
import ru.otus.mobile.config.TestAccount;
import ru.otus.mobile.config.TestContext;
import ru.otus.mobile.guice.SessionModule;
public final class MobileSessionFactory {
private final Injector rootInjector;
private final EmulatorQueue emulatorQueue;
private final MobileDriverFactory driverFactory;
private final LogcatCollector logcatCollector;
@Inject
public MobileSessionFactory(
Injector rootInjector,
EmulatorQueue emulatorQueue,
MobileDriverFactory driverFactory,
LogcatCollector logcatCollector
) {
this.rootInjector = rootInjector;
this.emulatorQueue = emulatorQueue;
this.driverFactory = driverFactory;
this.logcatCollector = logcatCollector;
}
public MobileSession create(TestAccount account, String testName) {
MobileConfig.Emulator emulator = emulatorQueue.acquire();
AndroidDriver driver = driverFactory.create(emulator);
TestContext context = new TestContext(account, emulator, driver, testName);
Injector childInjector = rootInjector.createChildInjector(new SessionModule(context));
return new MobileSession(context, childInjector, emulatorQueue, logcatCollector);
}
}
@@ -0,0 +1,58 @@
package ru.otus.mobile.extensions;
import com.google.inject.Guice;
import com.google.inject.Injector;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import ru.otus.mobile.annotations.MobileUser;
import ru.otus.mobile.config.TestAccount;
import ru.otus.mobile.db.DbClient;
import ru.otus.mobile.driver.MobileSession;
import ru.otus.mobile.driver.MobileSessionFactory;
import ru.otus.mobile.guice.CoreModule;
import ru.otus.mobile.page.LoginPage;
public final class MobileExtension implements
BeforeEachCallback,
AfterEachCallback {
private static final ExtensionContext.Namespace NAMESPACE =
ExtensionContext.Namespace.create(MobileExtension.class);
private static final String SESSION_KEY = "mobile.session";
@Override
public void beforeEach(ExtensionContext context) {
Injector injector = rootInjector(context);
TestAccount account = resolveAccount(context);
injector.getInstance(DbClient.class).resetData(account);
MobileSessionFactory sessionFactory = injector.getInstance(MobileSessionFactory.class);
MobileSession session = sessionFactory.create(account, context.getDisplayName());
session.activate();
session.injectMembers(context.getRequiredTestInstance());
context.getStore(NAMESPACE).put(SESSION_KEY, session);
injector.getInstance(LoginPage.class).login(account);
}
@Override
public void afterEach(ExtensionContext context) {
MobileSession session = context.getStore(NAMESPACE).remove(SESSION_KEY, MobileSession.class);
if (session != null) {
session.close();
}
}
private Injector rootInjector(ExtensionContext context) {
return context.getRoot()
.getStore(NAMESPACE)
.getOrComputeIfAbsent("root.injector", key -> Guice.createInjector(new CoreModule()), Injector.class);
}
private TestAccount resolveAccount(ExtensionContext context) {
MobileUser annotation = context.getRequiredTestClass().getAnnotation(MobileUser.class);
if (annotation == null) {
throw new IllegalStateException("Test class must be annotated with @MobileUser.");
}
return annotation.value();
}
}
@@ -0,0 +1,93 @@
package ru.otus.mobile.guice;
import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import com.google.inject.Singleton;
import ru.otus.mobile.config.MobileConfig;
import ru.otus.mobile.config.ProjectPaths;
import ru.otus.mobile.db.DbConfig;
import ru.otus.mobile.driver.EmulatorQueue;
import ru.otus.mobile.driver.LogcatCollector;
import ru.otus.mobile.driver.MobileDriverFactory;
import java.util.List;
public final class CoreModule extends AbstractModule {
@Override
protected void configure() {
}
@Provides
@Singleton
MobileConfig mobileConfig() {
String appPackage = value("app.package", "APP_PACKAGE", "ru.otus.wishlist");
String appUrl = value("app.url", "APP_URL", "http://wiremock:8080/wishlist.apk");
String reservationOwner = value("reservation.owner", "RESERVATION_OWNER", "user4us");
String rawEmulators = value(
"mobile.emulators",
"MOBILE_EMULATORS",
"emulator-1|http://localhost:4723|Android Emulator"
);
List<MobileConfig.Emulator> emulators = MobileConfig.Emulator.parse(rawEmulators, appUrl);
return new MobileConfig(appPackage, appUrl, reservationOwner, emulators);
}
@Provides
@Singleton
ProjectPaths projectPaths() {
return new ProjectPaths();
}
@Provides
@Singleton
DbConfig dbConfig() {
String url = requiredValue("db.url", "DB_URL");
String user = requiredValue("db.user", "DB_USER");
String password = requiredValue("db.password", "DB_PASSWORD");
return new DbConfig(url, user, password);
}
@Provides
@Singleton
EmulatorQueue emulatorQueue(MobileConfig config) {
return new EmulatorQueue(config);
}
@Provides
@Singleton
MobileDriverFactory mobileDriverFactory(MobileConfig config) {
return new MobileDriverFactory(config);
}
@Provides
@Singleton
LogcatCollector logcatCollector(ProjectPaths projectPaths) {
return new LogcatCollector(projectPaths);
}
private String requiredValue(String property, String env) {
String value = value(property, env, null);
if (value == null) {
throw new IllegalStateException(
"DB config is missing. Provide db.url/db.user/db.password or DB_URL/DB_USER/DB_PASSWORD."
);
}
return value;
}
private String value(String property, String env, String defaultValue) {
String propertyValue = System.getProperty(property);
if (!isBlank(propertyValue)) {
return propertyValue;
}
String environmentValue = System.getenv(env);
if (!isBlank(environmentValue)) {
return environmentValue;
}
return defaultValue;
}
private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
}
@@ -0,0 +1,30 @@
package ru.otus.mobile.guice;
import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import io.appium.java_client.android.AndroidDriver;
import ru.otus.mobile.config.TestAccount;
import ru.otus.mobile.config.TestContext;
public final class SessionModule extends AbstractModule {
private final TestContext testContext;
public SessionModule(TestContext testContext) {
this.testContext = testContext;
}
@Override
protected void configure() {
bind(TestContext.class).toInstance(testContext);
}
@Provides
TestAccount testAccount() {
return testContext.account();
}
@Provides
AndroidDriver androidDriver() {
return testContext.driver();
}
}
@@ -0,0 +1,19 @@
package ru.otus.mobile.page;
import ru.otus.mobile.component.BottomNavigationComponent;
import ru.otus.mobile.config.MobileConfig;
public abstract class AbsBasePage extends AbsPageObject {
protected final BottomNavigationComponent bottomNavigation;
private final MobileConfig config;
protected AbsBasePage(MobileConfig config, BottomNavigationComponent bottomNavigation) {
super(config);
this.config = config;
this.bottomNavigation = bottomNavigation;
}
protected MobileConfig config() {
return config;
}
}
@@ -0,0 +1,103 @@
package ru.otus.mobile.page;
import com.codeborne.selenide.CollectionCondition;
import com.codeborne.selenide.Condition;
import com.codeborne.selenide.SelenideElement;
import com.codeborne.selenide.WebDriverRunner;
import com.codeborne.selenide.appium.SelenideAppiumCollection;
import io.appium.java_client.AppiumBy;
import ru.otus.mobile.config.MobileConfig;
import static com.codeborne.selenide.appium.SelenideAppium.$$;
import static com.codeborne.selenide.appium.SelenideAppium.$;
public abstract class AbsPageObject {
private final MobileConfig config;
protected AbsPageObject(MobileConfig config) {
this.config = config;
}
protected SelenideElement byId(String id) {
return $(AppiumBy.id(fullId(id)));
}
protected SelenideAppiumCollection allById(String id) {
return $$(AppiumBy.id(fullId(id)));
}
protected SelenideElement byText(String text) {
return $(AppiumBy.androidUIAutomator("new UiSelector().text(\"" + escape(text) + "\")"));
}
protected SelenideElement byTextContains(String text) {
return $(AppiumBy.androidUIAutomator("new UiSelector().textContains(\"" + escape(text) + "\")"));
}
protected SelenideElement byUiAutomator(String selector) {
return $(AppiumBy.androidUIAutomator(selector));
}
protected SelenideElement byXpath(String xpath) {
return $(AppiumBy.xpath(xpath));
}
protected void type(String id, String value) {
SelenideElement input = byId(id).shouldBe(Condition.visible);
input.click();
input.clear();
input.sendKeys(value);
}
protected void tap(String id) {
byId(id).shouldBe(Condition.visible).click();
}
protected boolean exists(String id) {
return byId(id).exists();
}
protected void shouldHaveItems(String id) {
allById(id).shouldHave(CollectionCondition.sizeGreaterThan(0));
}
protected SelenideElement scrollToText(String text) {
String selector = "new UiScrollable(new UiSelector().scrollable(true))"
+ ".scrollIntoView(new UiSelector().textContains(\"" + escape(text) + "\"))";
return $(AppiumBy.androidUIAutomator(selector));
}
protected void back() {
WebDriverRunner.getWebDriver().navigate().back();
}
protected String xpathLiteral(String value) {
if (!value.contains("\"")) {
return "\"" + value + "\"";
}
if (!value.contains("'")) {
return "'" + value + "'";
}
String[] parts = value.split("\"");
StringBuilder builder = new StringBuilder("concat(");
for (int i = 0; i < parts.length; i++) {
if (i > 0) {
builder.append(", '\"', ");
}
builder.append("\"").append(parts[i]).append("\"");
}
builder.append(")");
return builder.toString();
}
private String fullId(String id) {
if (id.contains(":")) {
return id;
}
return config.appPackage() + ":id/" + id;
}
private String escape(String value) {
return value.replace("\"", "\\\"");
}
}
@@ -0,0 +1,36 @@
package ru.otus.mobile.page;
import com.codeborne.selenide.Condition;
import com.google.inject.Inject;
import ru.otus.mobile.component.BottomNavigationComponent;
import ru.otus.mobile.component.GiftFormComponent;
import ru.otus.mobile.config.MobileConfig;
public final class GiftsPage extends AbsBasePage {
private final GiftFormComponent form;
@Inject
public GiftsPage(MobileConfig config, BottomNavigationComponent bottomNavigation, GiftFormComponent form) {
super(config, bottomNavigation);
this.form = form;
}
public void createGift(String name) {
tap("add_button");
form.save(name, "100");
}
public void editGift(String oldName, String newName) {
openGift(oldName);
tap("edit_button");
form.save(newName, "100");
}
public void shouldSeeGift(String name) {
scrollToText(name).shouldBe(Condition.visible);
}
private void openGift(String name) {
scrollToText(name).click();
}
}
@@ -0,0 +1,30 @@
package ru.otus.mobile.page;
import com.google.inject.Inject;
import ru.otus.mobile.component.AlertDialogComponent;
import ru.otus.mobile.config.MobileConfig;
import ru.otus.mobile.config.TestAccount;
public final class LoginPage extends AbsPageObject {
private final AlertDialogComponent alertDialog;
@Inject
public LoginPage(MobileConfig config, AlertDialogComponent alertDialog) {
super(config);
this.alertDialog = alertDialog;
}
public void login(TestAccount account) {
if (!isOpened()) {
return;
}
type("username_text_input", account.username());
type("password_text_input", account.password());
tap("log_in_button");
alertDialog.acceptIfVisible();
}
public boolean isOpened() {
return exists("username_text_input") && exists("password_text_input");
}
}
@@ -0,0 +1,43 @@
package ru.otus.mobile.page;
import com.codeborne.selenide.Condition;
import com.google.inject.Inject;
import ru.otus.mobile.component.BottomNavigationComponent;
import ru.otus.mobile.config.MobileConfig;
public final class UsersPage extends AbsBasePage {
@Inject
public UsersPage(MobileConfig config, BottomNavigationComponent bottomNavigation) {
super(config, bottomNavigation);
}
public void open() {
bottomNavigation.openUsers();
byId("users_content").shouldBe(Condition.visible);
}
public void openUser(String username) {
String appPackage = config().appPackage();
String selector = "new UiScrollable(new UiSelector().resourceId(\"" + appPackage + ":id/users\"))"
+ ".setMaxSearchSwipes(12)"
+ ".scrollIntoView(new UiSelector().resourceId(\"" + appPackage + ":id/username\")"
+ ".textContains(\"" + username.replace("\"", "\\\"") + "\"))";
byUiAutomator(selector).shouldBe(Condition.visible).click();
}
public void openFirstWishlist() {
allById("wishlist_item").first().shouldBe(Condition.visible).click();
}
public void openFirstGift() {
allById("gift_item").first().shouldBe(Condition.visible).click();
}
public boolean isReserved() {
return Boolean.parseBoolean(byId("reserved").shouldBe(Condition.visible).getAttribute("checked"));
}
public void toggleReservation() {
tap("reserved");
}
}
@@ -0,0 +1,61 @@
package ru.otus.mobile.page;
import com.codeborne.selenide.Condition;
import com.google.inject.Inject;
import ru.otus.mobile.component.BottomNavigationComponent;
import ru.otus.mobile.component.WishlistFormComponent;
import ru.otus.mobile.config.MobileConfig;
public final class WishlistsPage extends AbsBasePage {
private final WishlistFormComponent form;
@Inject
public WishlistsPage(MobileConfig config, BottomNavigationComponent bottomNavigation, WishlistFormComponent form) {
super(config, bottomNavigation);
this.form = form;
}
public void open() {
bottomNavigation.openWishlists();
byId("wishlists_content").shouldBe(Condition.visible);
}
public void createWishlist(String name) {
tap("add_button");
form.save(name);
}
public void editWishlist(String oldName, String newName) {
ensureWishlistsList();
editButtonFor(oldName).shouldBe(Condition.visible).click();
form.save(newName);
}
public void shouldSeeWishlist(String name) {
scrollToText(name).shouldBe(Condition.visible);
}
public void openWishlist(String name) {
scrollToText(name).click();
}
private void ensureWishlistsList() {
if (exists("wishlist_item") || exists("wishlists")) {
return;
}
if (exists("gifts_content") || exists("gift_item") || exists("add_button")) {
back();
}
byId("wishlists_content").shouldBe(Condition.visible);
}
private com.codeborne.selenide.SelenideElement editButtonFor(String name) {
String full = config().appPackage();
String xpath = "//androidx.recyclerview.widget.RecyclerView[@resource-id='" + full + ":id/wishlists']"
+ "//android.view.ViewGroup[@resource-id='" + full + ":id/wishlist_item'"
+ " and .//android.widget.TextView[@resource-id='" + full + ":id/title'"
+ " and contains(@text," + xpathLiteral(name) + ")]]"
+ "//android.widget.Button[@resource-id='" + full + ":id/edit_button']";
return byXpath(xpath);
}
}
@@ -0,0 +1,9 @@
package ru.otus.mobile.util;
import java.time.Instant;
public final class TestData {
public String uniqueName(String prefix) {
return prefix + "-" + Instant.now().toEpochMilli();
}
}
@@ -1,147 +0,0 @@
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;
}
}
@@ -1,28 +0,0 @@
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;
}
}
@@ -1,118 +0,0 @@
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
);
}
}
@@ -1,16 +0,0 @@
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;
}
}
@@ -1,105 +0,0 @@
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;
}
}
@@ -1,85 +0,0 @@
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();
}
}
@@ -1,103 +0,0 @@
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);
}
}
}
@@ -1,77 +0,0 @@
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);
}
}
@@ -1,194 +0,0 @@
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();
}
}
@@ -1,180 +0,0 @@
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();
}
}
}
@@ -1,155 +0,0 @@
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);
}
}
}
@@ -1,132 +0,0 @@
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";
}
}
@@ -1,34 +1,41 @@
package ru.otus.mobile.tests;
import com.google.inject.Inject;
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 org.junit.jupiter.api.extension.ExtendWith;
import ru.otus.mobile.annotations.MobileUser;
import ru.otus.mobile.config.TestAccount;
import ru.otus.mobile.extensions.MobileExtension;
import ru.otus.mobile.page.GiftsPage;
import ru.otus.mobile.page.WishlistsPage;
import ru.otus.mobile.util.TestData;
public class GiftsTest extends BaseMobileTest {
@ExtendWith(MobileExtension.class)
@MobileUser(TestAccount.GIFTS)
public class GiftsTest {
@Inject
private WishlistsPage wishlistsPage;
@Inject
private GiftsPage giftsPage;
@Inject
private TestData testData;
@Test
@DisplayName("Создание и редактирование подарка")
void createAndEditGift() {
WishlistsScreen wishlists = new WishlistsScreen();
GiftsScreen gifts = new GiftsScreen();
String wishlistName = TestData.uniqueName("Wishlist");
String name = TestData.uniqueName("Gift");
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;
wishlistsPage.open();
wishlistsPage.createWishlist(wishlistName);
wishlistsPage.openWishlist(wishlistName);
giftsPage.createGift(name);
giftsPage.shouldSeeGift(name);
giftsPage.editGift(name, updated);
giftsPage.shouldSeeGift(updated);
}
}
@@ -1,27 +1,35 @@
package ru.otus.mobile.tests;
import com.google.inject.Inject;
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 org.junit.jupiter.api.extension.ExtendWith;
import ru.otus.mobile.annotations.MobileUser;
import ru.otus.mobile.config.MobileConfig;
import ru.otus.mobile.config.TestAccount;
import ru.otus.mobile.extensions.MobileExtension;
import ru.otus.mobile.page.UsersPage;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
@ExtendWith(MobileExtension.class)
@MobileUser(TestAccount.RESERVATION)
public class ReservationTest {
@Inject
private UsersPage usersPage;
@Inject
private MobileConfig mobileConfig;
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;
usersPage.open();
usersPage.openUser(mobileConfig.reservationOwnerUsername());
usersPage.openFirstWishlist();
usersPage.openFirstGift();
boolean before = usersPage.isReserved();
usersPage.toggleReservation();
assertNotEquals(before, usersPage.isReserved(), "Reservation status was not changed");
}
}
@@ -1,28 +1,34 @@
package ru.otus.mobile.tests;
import com.google.inject.Inject;
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 org.junit.jupiter.api.extension.ExtendWith;
import ru.otus.mobile.annotations.MobileUser;
import ru.otus.mobile.config.TestAccount;
import ru.otus.mobile.extensions.MobileExtension;
import ru.otus.mobile.page.WishlistsPage;
import ru.otus.mobile.util.TestData;
public class WishlistsTest extends BaseMobileTest {
@ExtendWith(MobileExtension.class)
@MobileUser(TestAccount.WISHLISTS)
public class WishlistsTest {
@Inject
private WishlistsPage wishlistsPage;
@Inject
private TestData testData;
@Test
@DisplayName("Создание и редактирование списка желаний")
void createAndEditWishlist() {
WishlistsScreen wishlists = new WishlistsScreen();
String name = TestData.uniqueName("Wishlist");
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;
wishlistsPage.open();
wishlistsPage.createWishlist(name);
wishlistsPage.shouldSeeWishlist(name);
wishlistsPage.editWishlist(name, updated);
wishlistsPage.shouldSeeWishlist(updated);
}
}
@@ -1,23 +0,0 @@
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 + "\")");
}
}
@@ -1,96 +0,0 @@
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<String, Object> 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);
}
}
@@ -1,14 +0,0 @@
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();
}
}
@@ -1,77 +0,0 @@
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;
}
}
@@ -1,19 +0,0 @@
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;
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"url": "/wishlist.apk"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/vnd.android.package-archive"
},
"bodyFileName": "wishlist.apk"
}
}