From f280b8c44c0d59ee5cba9009c232adb1c7772070 Mon Sep 17 00:00:00 2001 From: valerpan Date: Fri, 14 Aug 2026 16:47:31 +0300 Subject: [PATCH] Initial commit --- .gitignore | 19 + README.md | 275 ++++++++++++ fragments/DynamicFragmentExample.feature | 8 + fragments/Example.feature | 17 + images/allure.png | Bin 0 -> 17882 bytes images/allure_report.png | Bin 0 -> 45599 bytes images/allure_report_steps.png | Bin 0 -> 40313 bytes images/cucumber_compile.png | Bin 0 -> 7919 bytes images/run-feature.png | Bin 0 -> 14492 bytes pom.xml | 217 +++++++++ .../java/ru/lanit/at/actions/WebActions.java | 84 ++++ .../java/ru/lanit/at/actions/WebChecks.java | 216 +++++++++ src/main/java/ru/lanit/at/api/ApiRequest.java | 131 ++++++ .../listeners/RestAssuredCustomLogger.java | 63 +++ .../ru/lanit/at/api/models/RequestModel.java | 41 ++ .../at/api/properties/RestConfigurations.java | 17 + .../ru/lanit/at/aspects/FragmentsAspect.java | 54 +++ .../lanit/at/assertion/AssertErrorType.java | 17 + .../ru/lanit/at/assertion/AssertsManager.java | 62 +++ .../ru/lanit/at/assertion/ExtendedAssert.java | 69 +++ .../lanit/at/corecommonstep/CommonSteps.java | 23 + ...ustomGherkinMessagesDataTableArgument.java | 27 ++ ...ustomGherkinMessagesDocStringArgument.java | 38 ++ .../corecommonstep/fragment/CustomStep.java | 116 +++++ .../fragment/FragmentReplacer.java | 425 ++++++++++++++++++ .../fragment/GherkinResource.java | 30 ++ .../fragment/GherkinSerializer.java | 146 ++++++ src/main/java/ru/lanit/at/hooks/WebHooks.java | 50 +++ .../java/ru/lanit/at/pages/GooglePage.java | 17 + .../ru/lanit/at/pages/GoogleResultPage.java | 15 + .../java/ru/lanit/at/steps/VariableSteps.java | 51 +++ .../java/ru/lanit/at/steps/api/ApiSteps.java | 80 ++++ .../type_params/DataTableTypeContainer.java | 21 + .../lanit/at/steps/web/AbstractWebSteps.java | 46 ++ .../ru/lanit/at/steps/web/DebugWebSteps.java | 17 + .../lanit/at/steps/web/WebActionWebSteps.java | 154 +++++++ .../lanit/at/steps/web/WebCheckWebSteps.java | 143 ++++++ .../ru/lanit/at/steps/web/WindowWebSteps.java | 103 +++++ .../steps/web/pages/GooglePageWebSteps.java | 16 + .../java/ru/lanit/at/utils/CompareUtil.java | 62 +++ .../java/ru/lanit/at/utils/ContextHolder.java | 48 ++ .../java/ru/lanit/at/utils/DataGenerator.java | 56 +++ .../java/ru/lanit/at/utils/ErrorMessage.java | 10 + src/main/java/ru/lanit/at/utils/FileUtil.java | 95 ++++ src/main/java/ru/lanit/at/utils/JsonUtil.java | 58 +++ .../java/ru/lanit/at/utils/RegexUtil.java | 44 ++ src/main/java/ru/lanit/at/utils/Sleep.java | 26 ++ src/main/java/ru/lanit/at/utils/Stand.java | 27 ++ .../java/ru/lanit/at/utils/VariableUtil.java | 76 ++++ .../ru/lanit/at/utils/VideoSaveHelper.java | 78 ++++ .../lanit/at/utils/allure/AllureHelper.java | 92 ++++ .../lanit/at/utils/allure/AllureLogger.java | 125 ++++++ .../at/utils/reflections/ReflectionUtil.java | 31 ++ .../at/utils/selenide/DriverManager.java | 94 ++++ .../at/utils/selenide/command/Commands.java | 45 ++ .../selenide/extensions/CustomCommands.java | 19 + .../extensions/CustomSelenidePageFactory.java | 7 + .../lanit/at/utils/web/annotations/Name.java | 15 + .../at/utils/web/pagecontext/Environment.java | 64 +++ .../at/utils/web/pagecontext/PageCache.java | 59 +++ .../at/utils/web/pagecontext/PageManager.java | 30 ++ .../at/utils/web/pagecontext/WebPage.java | 126 ++++++ .../utils/web/properties/Configurations.java | 40 ++ .../web/properties/WebConfigurations.java | 52 +++ src/main/resources/META-INF/aop.xml | 6 + .../com.codeborne.selenide.commands.Commands | 1 + ....codeborne.selenide.impl.PageObjectFactory | 1 + ...meta.allure.listener.StepLifecycleListener | 1 + ...meta.allure.listener.TestLifecycleListener | 1 + src/main/resources/log4j.properties | 5 + src/test/java/Runner.java | 22 + src/test/resources/config/chrome.properties | 5 + .../resources/config/configuration.properties | 10 + src/test/resources/config/edge.properties | 6 + src/test/resources/config/firefox.properties | 6 + src/test/resources/cucumber.properties | 1 + src/test/resources/features/api/Books.feature | 25 ++ .../resources/features/api/Github.feature | 31 ++ .../resources/features/api/PetStore.feature | 56 +++ .../resources/features/web/Example.feature | 18 + .../features/web/FragmentExample.feature | 24 + src/test/resources/json/createUser.json | 10 + src/test/resources/log4j.properties | 5 + src/test/resources/suite.xml | 9 + 84 files changed, 4430 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 fragments/DynamicFragmentExample.feature create mode 100644 fragments/Example.feature create mode 100644 images/allure.png create mode 100644 images/allure_report.png create mode 100644 images/allure_report_steps.png create mode 100644 images/cucumber_compile.png create mode 100644 images/run-feature.png create mode 100644 pom.xml create mode 100644 src/main/java/ru/lanit/at/actions/WebActions.java create mode 100644 src/main/java/ru/lanit/at/actions/WebChecks.java create mode 100644 src/main/java/ru/lanit/at/api/ApiRequest.java create mode 100644 src/main/java/ru/lanit/at/api/listeners/RestAssuredCustomLogger.java create mode 100644 src/main/java/ru/lanit/at/api/models/RequestModel.java create mode 100644 src/main/java/ru/lanit/at/api/properties/RestConfigurations.java create mode 100644 src/main/java/ru/lanit/at/aspects/FragmentsAspect.java create mode 100644 src/main/java/ru/lanit/at/assertion/AssertErrorType.java create mode 100644 src/main/java/ru/lanit/at/assertion/AssertsManager.java create mode 100644 src/main/java/ru/lanit/at/assertion/ExtendedAssert.java create mode 100644 src/main/java/ru/lanit/at/corecommonstep/CommonSteps.java create mode 100644 src/main/java/ru/lanit/at/corecommonstep/fragment/CustomGherkinMessagesDataTableArgument.java create mode 100644 src/main/java/ru/lanit/at/corecommonstep/fragment/CustomGherkinMessagesDocStringArgument.java create mode 100644 src/main/java/ru/lanit/at/corecommonstep/fragment/CustomStep.java create mode 100644 src/main/java/ru/lanit/at/corecommonstep/fragment/FragmentReplacer.java create mode 100644 src/main/java/ru/lanit/at/corecommonstep/fragment/GherkinResource.java create mode 100644 src/main/java/ru/lanit/at/corecommonstep/fragment/GherkinSerializer.java create mode 100644 src/main/java/ru/lanit/at/hooks/WebHooks.java create mode 100644 src/main/java/ru/lanit/at/pages/GooglePage.java create mode 100644 src/main/java/ru/lanit/at/pages/GoogleResultPage.java create mode 100644 src/main/java/ru/lanit/at/steps/VariableSteps.java create mode 100644 src/main/java/ru/lanit/at/steps/api/ApiSteps.java create mode 100644 src/main/java/ru/lanit/at/steps/api/type_params/DataTableTypeContainer.java create mode 100644 src/main/java/ru/lanit/at/steps/web/AbstractWebSteps.java create mode 100644 src/main/java/ru/lanit/at/steps/web/DebugWebSteps.java create mode 100644 src/main/java/ru/lanit/at/steps/web/WebActionWebSteps.java create mode 100644 src/main/java/ru/lanit/at/steps/web/WebCheckWebSteps.java create mode 100644 src/main/java/ru/lanit/at/steps/web/WindowWebSteps.java create mode 100644 src/main/java/ru/lanit/at/steps/web/pages/GooglePageWebSteps.java create mode 100644 src/main/java/ru/lanit/at/utils/CompareUtil.java create mode 100644 src/main/java/ru/lanit/at/utils/ContextHolder.java create mode 100644 src/main/java/ru/lanit/at/utils/DataGenerator.java create mode 100644 src/main/java/ru/lanit/at/utils/ErrorMessage.java create mode 100644 src/main/java/ru/lanit/at/utils/FileUtil.java create mode 100644 src/main/java/ru/lanit/at/utils/JsonUtil.java create mode 100644 src/main/java/ru/lanit/at/utils/RegexUtil.java create mode 100644 src/main/java/ru/lanit/at/utils/Sleep.java create mode 100644 src/main/java/ru/lanit/at/utils/Stand.java create mode 100644 src/main/java/ru/lanit/at/utils/VariableUtil.java create mode 100644 src/main/java/ru/lanit/at/utils/VideoSaveHelper.java create mode 100644 src/main/java/ru/lanit/at/utils/allure/AllureHelper.java create mode 100644 src/main/java/ru/lanit/at/utils/allure/AllureLogger.java create mode 100644 src/main/java/ru/lanit/at/utils/reflections/ReflectionUtil.java create mode 100644 src/main/java/ru/lanit/at/utils/selenide/DriverManager.java create mode 100644 src/main/java/ru/lanit/at/utils/selenide/command/Commands.java create mode 100644 src/main/java/ru/lanit/at/utils/selenide/extensions/CustomCommands.java create mode 100644 src/main/java/ru/lanit/at/utils/selenide/extensions/CustomSelenidePageFactory.java create mode 100644 src/main/java/ru/lanit/at/utils/web/annotations/Name.java create mode 100644 src/main/java/ru/lanit/at/utils/web/pagecontext/Environment.java create mode 100644 src/main/java/ru/lanit/at/utils/web/pagecontext/PageCache.java create mode 100644 src/main/java/ru/lanit/at/utils/web/pagecontext/PageManager.java create mode 100644 src/main/java/ru/lanit/at/utils/web/pagecontext/WebPage.java create mode 100644 src/main/java/ru/lanit/at/utils/web/properties/Configurations.java create mode 100644 src/main/java/ru/lanit/at/utils/web/properties/WebConfigurations.java create mode 100644 src/main/resources/META-INF/aop.xml create mode 100644 src/main/resources/META-INF/defaultservices/com.codeborne.selenide.commands.Commands create mode 100644 src/main/resources/META-INF/defaultservices/com.codeborne.selenide.impl.PageObjectFactory create mode 100644 src/main/resources/META-INF/services/io.qameta.allure.listener.StepLifecycleListener create mode 100644 src/main/resources/META-INF/services/io.qameta.allure.listener.TestLifecycleListener create mode 100644 src/main/resources/log4j.properties create mode 100644 src/test/java/Runner.java create mode 100644 src/test/resources/config/chrome.properties create mode 100644 src/test/resources/config/configuration.properties create mode 100644 src/test/resources/config/edge.properties create mode 100644 src/test/resources/config/firefox.properties create mode 100644 src/test/resources/cucumber.properties create mode 100644 src/test/resources/features/api/Books.feature create mode 100644 src/test/resources/features/api/Github.feature create mode 100644 src/test/resources/features/api/PetStore.feature create mode 100644 src/test/resources/features/web/Example.feature create mode 100644 src/test/resources/features/web/FragmentExample.feature create mode 100644 src/test/resources/json/createUser.json create mode 100644 src/test/resources/log4j.properties create mode 100644 src/test/resources/suite.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e27cba5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +*/target/* +*/allure-results/* +.idea/* +*.iml +*.class +*.log +*.ctxt +.mtj.tmp/ +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar +hs_err_pid* +*.allure/* +.build/ +idea/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..50ef1d2 --- /dev/null +++ b/README.md @@ -0,0 +1,275 @@ +## Шаблон Фреймворка для запуска автотестов +BDD фреймворк для автотестов на Java, использующий: +- [Selenide](https://ru.selenide.org) для тестирования Web UI +- [Cucumber](https://cucumber.io) для написания сценариев в стиле BDD +- [REST assured](https://rest-assured.io) для тестирования REST API + +## Как начать писать WEB автотесты +## 1. Page Objects +В модуле ***autotest-web*** в директории ***src/main/java/pages*** находятся классы ***PageObjects***
+**1.1** Каждый ***PageObject*** должен наследоваться от класса ***WebPage***
+**1.2** Над классом необходимо проставить аннотацию *@Name(**value** = "<имя страницы>")*
+Пример:
+```java +@Name(value = "Главная страница приложения") +public class MainPage extends WebPage { + + @Name("Слайдер") + private SelenideElement uuid = $(".slick-track"); +} +``` +## 2. Степы +**2.1** В классе со степами необходимо наследовать от класса AbstractWebSteps и конструктор класса следующим образом:
+```java +public class WebActionSteps extends AbstractWebSteps { + + public WebActionSteps(PageManager pageManager) { + super(pageManager); + } + // steps +} +``` +* ссылка ***pageManager*** хранит в себе инициализированный контекст текущей страницы, с помощью которой можно достать элемент через ***value*** аннотации ***@Name*** элемента
+* При компиляции ссылка ***pageManager*** проинициализируется автоматически путем Dependency Injection через PicoContainer
+* Более подробно о подходе можно ознакомиться по ссылке [Cucumber PicoContainer](https://cucumber.io/docs/cucumber/state/)
+ + + +**2.2** Пример инициализации страницы:
+Для того, чтобы получить доступ к элементу, нам необходимо перед этим проинициализировать ***PageObject***
+**pageName** - это value аннотации **Name** класса ***PageObject*** - в нашем примере *"Google"* +```java +public void setPage(String pageName) { + WebPage page = getPage(pageName); + pageManager.setCurrentPage(page); +} +``` +**2.3** Теперь страница проинициализирована и получить доступ к элементам можно по его имени ***(value)***
+```java +@Если("кликнуть на элемент {string}") +public void clickOnElement(String elementName) { + SelenideElement element = pageManager + .getCurrentPage() + .getElement(elementName); + element.shouldBe(visible).click(); +} +``` + +**2.4** Осуществление мягких проверок (SoftAssert)
+В конфигурации Selenide есть параметр Configuration.assertionMode, который не отрабатывает должным образом в связке с Cucumber и потому его использование в проекте не допускается. +Для осуществления мягких проверок следует использовать класс ru.lanit.at.assertion.AssertsManager. +Пример использования (Проверка утверждений у SelenideElement) + +```java +public static void elementContainsText(SelenideElement element, String text) { + element.execute(Commands.checkSoft(Condition.text(text), Duration.ofSeconds(10))); +} +``` + +**2.5** Расширение и изменения методов SelenideElement с помощью механизма SPI
+Если нужно изменить поведение стандартных методов SelenideElement, это можно сделать след образом. +В классе ru.lanit.at.utils.selenide.extensions.CustomCommands, необходимо релизовать свою логику, например код ниже, будет перед осуществлением клика, прикладывать скриншот элемента, на который будет происходить нажатие. +```java +public T execute(Object proxy, WebElementSource webElementSource, String methodName, @Nullable Object[] args) throws IOException { + if(methodName.equals("click")){ + addAllureScreenshootElement((SelenideElement)proxy); + } + return super.execute(proxy, webElementSource, methodName, args); + } + + private void addAllureScreenshootElement(SelenideElement selenideElement){ + AllureHelper.attachScreenShot("Клик на элементе ", selenideElement.getScreenshotAs(OutputType.BYTES)); + } +``` +Изменения логики формирования Page объектов, осуществляется в классе ru.lanit.at.utils.selenide.extensions.CustomSelenidePageFactory. С помощью этого класса можно расширить/изменить логику инициализации элементов. + +## 3. Тесты + +**3.1** Написание сценариев +```gherkin +#language:ru +Функционал: Поиск гугл + Сценарий: Открытие страницы google.com, ввод значения в поиск + + * открыть браузер + * инициализация страницы "Google" + * ввести в поле "поле поиска" значение "Погода в Москве" + * на странице имеется элемент "результаты поиска" + * кликнуть на элемент "кнопка поиска" + * инициализация страницы "страница результатов поиска" + * на странице присутствует текст "Погода в Москве" +``` +* Шаг 1 - открытие веб страницы +* Шаг 2 - инициализация ***PageObject*** через его ***value*** аннотации ***@Name*** +* Шаг 3 - как в примере **2.3** получаем текущий элемент по его ***value*** аннотации ***@Name*** и производим действия/проверки + +**3.2** Сценарии из фрагментов +Начиная с версии 1.2, фреймворк поддержиивает напиисание сценариев из фрагментов. Фрагменты должны храниться в +директории "fragments" в корне проекта в файлах с расширением *.feature. Каждый фрагмент должен быть выделен в отдельный +сценарий и аннотирован @fragment +```gherkin +@fragment +Сценарий: открытие виджета + + * на странице имеется элемент "кнопка поиска" + * кликнуть на элемент "кнопка поиска" + * переход на страницу "Google страница результатов" + * на странице имеется элемент "виджет погоды" +``` +Чтобы использовать фрагмент, в сценарии следует добавить шаг вида + +```gherkin + * вызвать фрагмент "открытие виджета" +``` +Передаваемый текстовый параметр шага должен соответвовать имени сценария, содержащего фрагмент. Перед запуском тестов, +фреймворк анализирует содержимое всех фича-файлов и заменяет шаг * вызвать фрагмент"{string}" на группу шагов из +соотвествующего фрагмента. +!IMPORTANT! +Чтобы функционал работал корректно, компиляцию проекта необходимо выполнять при помощи команды mvn compile. +Для запуска фич по отдельности через функционал или отладчик IDEA внесите измение в шаблон конфигураци Cucumber: +Run/Debug Configurations/ Edit configurations template / Cucumber Java / Build Добавить действие Maven "compile" +![feature](images/cucumber_compile.png) +Для запуска фрагментов через Maven дополнительных настроек не требуется. + +## 4. Настройки +В директории ***autotest-web/src/test/resources/config*** имеются примеры *config-файлов* для разных браузеров и общих настроек запуска. + +configuration.properties +```properties +stand= названия стенда для тестирования в классе (ru.lanit.at.utils.Stand) содержатся адреса стендов +screen_after_step=false - необходимость прикреплять скриншот к каждому шагу +baseUrl=https://petstore.swagger.io/v2/ - базовый url для апи запросов + +remoteUrl=127.0.0.1:4444 - адрес удаленного хаба/selenoid. +enableVNC=true - возможность отображения работы теста в браузере, переменная применима только selenoid. +enableVideo=true - возможность записывать видео, переменная применима только selenoid. +enableLog=true - флаг для сохранения логов selenoid контейнера, переменная применима только selenoid. + +``` + +chrome.properties +```properties +webdriver.browser.size=1920x1080 - разрешение браузера +webdriver.browser.name=chrome - название браузера +webdriver.timeoutSeconds=4 - таймаут ожидания состояния веб-элементов +polling.timeoutMs=200 - периодичность опроса веб-элемента +webdriver.version=91.0 - версия веб-драйвера + +``` +Так же с помощью одноименных переменных окружения можно переопределить данные настройки
+Драйвера скачиваются с помощью **WebDriverManager**, учитывайте это если находитесь в закрытом контуре + +## Как начать писать API тесты +### Принцип написания тестов похож на подход создания и отправки запроса в Postman +*Шаг 1. Конфигурируем запрос с помощью шага* +```gherkin +* создать запрос + | method | path | body | url | +``` +Если какой то из столбцов не указан в данном шаге, то он не учитывается в запросе +Например: +```gherkin +* создать запрос + | method | path | body | + | POST | /user | createUser.json | +ИЛИ +* создать запрос + | method | path | body | + | POST | /user | {<тело запроса>} | +ИЛИ +* создать запрос + | method | url | + | GET | https://petstore.swagger.io/v2/user/ | +``` +* Можно указать ***basePath*** через одноименную системную переменную или в файле конфигурации ***configuration.properties***. Тогда вместо столбца url можно указывать просто path. И наоборот, если указать столбец url с полным url хоста и path то basePath не учитывается, даже если указан с системных переменных. +* Тело запроса - в качестве тела можно передать в таблицу, как просто текст, так и название файла ***json***, которое будет лежать по пути ***autotest-rest/src/test/resources/json*** + +*Шаг 2. Добавление Headers и Query* +```gherkin +* добавить header + | Content-Type | application/json | +* добавить query параметры + | city | Moscow | +``` +*Шаг 3. Отправка запроса* +```gherkin +* отправить запрос +``` +*Шаг 4. Проверка ответа* +```gherkin +* статус код 200 +``` +Если необходимо проверить тело ответа, то данные можно вытащить с помощью jsonpath. Значение сохранится в переменную из столбца 1
+```gherkin +* извлечь данные + | user_id | $.message | +``` +Проверить извлеченные данные можно с помощью шага: +```gherkin +* сравнить значения +| ${user_id} | != | null | +ИЛИ +| ${user_id} | == | 1234567890 | +ИЛИ +| ${user_id} | > | 0 | +ИЛИ +| ${user_id} | < | 100 | +ИЛИ +| ${user_id} | содержит | qwerty123 | +``` +### Иная информация. +С помощью следующего шага можно сгенерить переменные для последующего использования в тесте +```gherkin +* сгенерировать переменные + | id | 0 | + | username | EEEEEEEE | + | firstName | EEEEEEEE | + | lastName | EEEEEEEE | + | email | EEEEEEE@EEEDDD.EE | + | password | DDDEEEDDDEEE | +``` +**R** - случайная русская буква
+**E** - случайная английская буква
+**D** - случайное число
+Другие символы в строке игнорируются и остаются неизменяемыми +Сгенерированные значения хранятся в контексте теста. Их можно подставлять в запросы, тела запросов. Достать их можно используя синтаксис ***${username}***
+ +### Запуск тестов через консоль + +```java +mvn clean test -Ddataproviderthreadcount=4 -Dscreen_after_step=false -Dtags="@authentication" + ``` +* ***-Ddataproviderthreadcount=4*** - кол-во поток выполнения, значение по умолчанию 1 поток (Изменение дефолтного кол-ва поток производится в файле src\test\resources\suite.xml) + +* ***-Dscreen_after_step=false*** - Автоматическое снятие скриншотов после каждого шага. Дефолтное значение =false (Изменение дефолтного значения производится в файле src\test\resources\config\configuration.properties) + +* ***-Dtags="@authentication"*** - Выбор тестов с определенным тегом. Дефолтное значение отсутствует, т.е. если в запуске не указывать данный параметр то, будет запущены все фичи файлы. + +>Тажке в командную строку можно передать любой параметр из файлов *.properties +>Для добавление нового параметра, его нужно будет прописать в соответствующем .properties файле и добавить соответствущий полю getter в java классе. + +#### Для запуска с дефолтными параметрами используется команда. +```java +mvn clean test + ``` + + +Так же запустить тесты можно через плагин Cucumber (предварительно установив его в Idea), для этого необходимо открыть любой feature-файл, и кликнуть по зеленой стрелке рядом со стройкой **Функционал** или **Сценарий**
+![feature](images/run-feature.png) + +### Генерация отчета +По итогу прогонов можно сгенерить _Allure отчет_, для этого необходимо в Intellij Idea кликнуть на строку **Maven** в правом верхнем углу IDE и следовать инструкции по пунктам ниже:
+В **п.1** необходимо выбрать тот модуль, в котором запускались тесты.
+![allure](images/allure.png)
+**По итогу сформируется Html страница с отчетом.**
+![allure-report](images/allure_report.png)
+**В отчете можно провалиться в каждый шаг и посмотреть информацию по нему**
+![allure-step](images/allure_report_steps.png)
+ +где +* Зеленым цветом отмечены - успешно прошедшие тесты +* Желтым цветом отмечены - тесты в которых есть неблокирующие дефекты +* Серым цветом отмечены - тесты который были пропущены +* Красным цветом отмечены - тесты с блокирующим дефектом + +**Шаги с неблокирующими дефекты** помечаются в отчете желтым цветом. По окончании теста отображается информация о всех подобных дефектах. diff --git a/fragments/DynamicFragmentExample.feature b/fragments/DynamicFragmentExample.feature new file mode 100644 index 0000000..9c1ceb0 --- /dev/null +++ b/fragments/DynamicFragmentExample.feature @@ -0,0 +1,8 @@ +#language:ru +Функционал: динамический фрагмент + + @fragment + Сценарий: открытие страницы и ввод текста из параметра + * открыть url "https://www.google.ru/" + * инициализация страницы "Google" + * ввести в поле "поле поиска" значение "<текст_для_ввода>" \ No newline at end of file diff --git a/fragments/Example.feature b/fragments/Example.feature new file mode 100644 index 0000000..f93cef8 --- /dev/null +++ b/fragments/Example.feature @@ -0,0 +1,17 @@ +#language:ru +Функционал: фрагмент + + @fragment + Сценарий: открытие виджета + + * на странице имеется элемент "кнопка поиска" + * кликнуть на элемент "кнопка поиска" + * переход на страницу "Google страница результатов" + * на странице имеется элемент "виджет погоды" + + @fragment + Сценарий: открытие страницы google + + * открыть url "https://www.google.ru/" + * инициализация страницы "Google" + * ввести в поле "поле поиска" значение "Погода в Москве" \ No newline at end of file diff --git a/images/allure.png b/images/allure.png new file mode 100644 index 0000000000000000000000000000000000000000..e3f3f14a562933b696d870f10f717510216f7843 GIT binary patch literal 17882 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 literal 0 HcmV?d00001 diff --git a/images/allure_report.png b/images/allure_report.png new file mode 100644 index 0000000000000000000000000000000000000000..2daf289de6f45d8b2d86497b5b2360d95648fe62 GIT binary patch literal 45599 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 S^RP$_Jh-Q?S$^00<-Y-Pad>zD literal 0 HcmV?d00001 diff --git a/images/run-feature.png b/images/run-feature.png new file mode 100644 index 0000000000000000000000000000000000000000..e9e6126d00038407d28e4566d4cf7f9c8754cd1c GIT binary patch literal 14492 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 p|C0Rw + + 4.0.0 + + ru.lanit.at + autotest-template-with-cucumber + 1.2-SNAPSHOT + + UTF-8 + 8 + 8 + 1.0.12 + 7.2.3 + 1.9.6 + 1.14.0 + 2.22.2 + 6.2.0 + 2.15.0 + 1.7.30 + 3.8.1 + 2.17.2 + 2.10.0 + 3.12.0 + 4.4.0 + 2.12.3 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + ${java.source} + ${java.target} + ${project.build.sourceEncoding} + + + + org.apache.maven.plugins + maven-surefire-plugin + ${surefire.version} + + + src/test/resources/suite.xml + + false + false + + -Dcucumber.filter.tags="${tags}" + -Dfile.encoding=UTF-8 + -javaagent:"${settings.localRepository}/org/aspectj/aspectjweaver/${aspectj.version}/aspectjweaver-${aspectj.version}.jar" + + + ${project.basedir}/ + ${project.build.directory}/allure-results + ${project.build.directory}/report + ${project.build.directory}/download + + + + + org.aspectj + aspectjweaver + ${aspectj.version} + + + + + io.qameta.allure + allure-maven + ${allure.maven.version} + + ${project.build.directory}/allure-report + + + + org.codehaus.mojo + aspectj-maven-plugin + ${aspectj-maven-plugin.version} + + + org.aspectj + aspectjtools + ${aspectj.version} + + + + ${java.source} + ${java.source} + ${java.source} + ${project.build.sourceEncoding} + + + io.cucumber + cucumber-testng + + + io.cucumber + cucumber-core + + + true + + + + + compile + test-compile + + + + + + + + + + + + io.cucumber + cucumber-java + ${cucumber.version} + + + io.cucumber + cucumber-testng + ${cucumber.version} + + + io.cucumber + cucumber-picocontainer + ${cucumber.version} + + + + + io.qameta.allure + allure-cucumber7-jvm + 2.17.2 + + + io.qameta.allure + allure-selenide + ${allure.version} + + + + + org.aspectj + aspectjrt + ${aspectj.version} + + + org.slf4j + slf4j-api + ${slf4j.version} + + + org.slf4j + slf4j-log4j12 + ${slf4j.version} + + + org.apache.logging.log4j + log4j-core + ${log4j.version} + + + com.jayway.jsonpath + json-path + 2.5.0 + + + org.apache.commons + commons-lang3 + ${common-lang3.version} + + + com.codeborne + selenide + ${selenide.version} + + + org.reflections + reflections + 0.9.10 + + + org.aeonbits.owner + owner-java8 + ${owner.version} + + + + + io.rest-assured + rest-assured + ${restassured.version} + + + com.googlecode.json-simple + json-simple + 1.1.1 + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + + \ No newline at end of file diff --git a/src/main/java/ru/lanit/at/actions/WebActions.java b/src/main/java/ru/lanit/at/actions/WebActions.java new file mode 100644 index 0000000..102f2ad --- /dev/null +++ b/src/main/java/ru/lanit/at/actions/WebActions.java @@ -0,0 +1,84 @@ +package ru.lanit.at.actions; + +import com.codeborne.selenide.Selenide; +import com.codeborne.selenide.SelenideElement; +import com.codeborne.selenide.WebDriverRunner; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testng.Assert; +import ru.lanit.at.utils.ErrorMessage; +import ru.lanit.at.utils.Sleep; + +import java.util.ArrayList; +import java.util.List; + + +public class WebActions { + + private static final Logger LOGGER = LoggerFactory.getLogger(WebActions.class); + + /** + * Открывает ссылку по переданному url и переводит контекст драйвера на новое окно + */ + public static void openUrlOnNewTab(String url) { + String command = String.format("window.open('%s')", url); + Selenide.executeJavaScript(command); + List handles = new ArrayList<>(WebDriverRunner.getWebDriver().getWindowHandles()); + Selenide.switchTo().window(handles.get(handles.size() - 1)); + LOGGER.info("Отрытие новой вкладки с урл {}", url); + } + + /** + * Переключается на следующую вкладку или вкладку по порядковому номеру (1, 2, ...) + */ + public static void switchToNextTab(Integer tabNumber) { + List handles = new ArrayList<>(WebDriverRunner.getWebDriver().getWindowHandles()); + if (tabNumber != null) { + Assert.assertTrue(handles.size() >= tabNumber, String.format(ErrorMessage.BROWSER_TAB_NUMBER_MORE_THAN_TABS, tabNumber)); + } + tabNumber = tabNumber == null ? handles.size() - 1 : tabNumber - 1; + Selenide.switchTo().window(handles.get(tabNumber)); + LOGGER.info("Переключение на вкладку {}", handles.size()); + } + + /** + * Закрывает текущую вкладку и переключается на предыдущую + */ + public static void closeCurrentTabAndSwitchToPrevious() { + Selenide.closeWindow(); + List handles = new ArrayList<>(WebDriverRunner.getWebDriver().getWindowHandles()); + Selenide.switchTo().window(handles.get(handles.size() - 1)); + } + + /** + * Посимвольное заполнение поля + * + * @param element - элемент + * @param text - значение + */ + public static void fillInputByCharacter(SelenideElement element, String text) { + for (char character : text.toCharArray()) { + element.sendKeys(String.valueOf(character)); + Sleep.pauseSec(1); + } + } + + /** + * Установка чекбокса на элементе + * + * @param element - элемент + * @param flag - значение + */ + public static void setCheckBox(SelenideElement element, boolean flag) { + element.click(); + if (flag) { + if (!element.isSelected()) { + element.click(); + } + } else { + if (element.isSelected()) { + element.click(); + } + } + } +} diff --git a/src/main/java/ru/lanit/at/actions/WebChecks.java b/src/main/java/ru/lanit/at/actions/WebChecks.java new file mode 100644 index 0000000..35c7e8b --- /dev/null +++ b/src/main/java/ru/lanit/at/actions/WebChecks.java @@ -0,0 +1,216 @@ +package ru.lanit.at.actions; + + +import com.codeborne.selenide.Condition; +import com.codeborne.selenide.Selectors; +import com.codeborne.selenide.SelenideElement; +import com.codeborne.selenide.WebDriverRunner; +import com.codeborne.selenide.ex.ElementNotFound; +import com.codeborne.selenide.ex.ElementShould; +import org.aeonbits.owner.ConfigFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testng.Assert; +import ru.lanit.at.utils.ErrorMessage; +import ru.lanit.at.utils.selenide.command.Commands; +import ru.lanit.at.utils.web.properties.WebConfigurations; + +import java.time.Duration; +import java.util.List; + +import static com.codeborne.selenide.Selenide.$; +import static java.lang.String.format; + +public class WebChecks { + + private static final Logger LOGGER = LoggerFactory.getLogger(WebChecks.class); + + + private static Integer getTimeoutSeconds(Integer timeout) { + WebConfigurations cfg = ConfigFactory.create(WebConfigurations.class); + return timeout == null ? cfg.webDriverTimeoutSeconds() : timeout; + } + + /** + * Проверяет что текущий url равен переданному + * + * @param url ожидаемый url + */ + public static void urlEquals(String url) { + Assert.assertEquals(WebDriverRunner.getWebDriver().getCurrentUrl(), url, + format(ErrorMessage.URL_NOT_EQUAL_ACTUAL, url, WebDriverRunner.getWebDriver().getCurrentUrl())); + LOGGER.info("url '{}' равен текущему '{}'", url, WebDriverRunner.getWebDriver().getCurrentUrl()); + } + + /** + * Проверяет что текущий url содержит текст + * + * @param text ожидаемый текст + */ + public static void urlContains(String text) { + Assert.assertTrue(WebDriverRunner.getWebDriver().getCurrentUrl().contains(text), + format(ErrorMessage.URL_NOT_CONTAINS_TEXT, WebDriverRunner.getWebDriver().getCurrentUrl(), text)); + LOGGER.info("Текущий url '{}' содержит текст '{}'", WebDriverRunner.getWebDriver().getCurrentUrl(), text); + } + + /** + * Проверяет, что атрибут элемента равен ожидаемому + * + * @param element Элемент + * @param attrName Атрибут + * @param expectValue Ожидаемое значение атрибута + */ + public static void checkAttribute(SelenideElement element, String attrName, String expectValue, Integer timeoutSeconds) { + int timeout = getTimeoutSeconds(timeoutSeconds); + element + .shouldBe(Condition.exist, Duration.ofSeconds(timeout)) + .shouldBe(Condition.attribute(attrName, expectValue), Duration.ofSeconds(timeout)); + } + + /** + * Проверяет, что на странице доступен элемент + */ + public static void elementEnablesOnPage(SelenideElement element, Integer timeoutSeconds) { + int timeout = getTimeoutSeconds(timeoutSeconds); + element.shouldBe(Condition.enabled, Duration.ofSeconds(timeout)); + } + + + /** + * Проверяет, что на странице имеется элемент + */ + public static void elementVisibleOnPage(SelenideElement element, Integer timeoutSeconds) { + int timeout = getTimeoutSeconds(timeoutSeconds); + element.shouldBe(Condition.visible, Duration.ofSeconds(timeout)); + } + + + /** + * Проверяет, что элемент имеет текст + */ + public static void elementContainsText(SelenideElement element, String text) { + element.execute(Commands.checkSoft(Condition.text(text), null)); + } + + /** + * Проверяет, что элемент содержит текст + */ + public static void elementContainsText(SelenideElement element, List texts) { + for (String s : texts) { + element.execute(Commands.checkSoft(Condition.text(s), Duration.ofSeconds(10))); + } + } + + + /** + * Проверяет, что на странице имеется текст + */ + public static void textVisibleOnPage(String text, Integer timeoutSeconds) { + int timeout = getTimeoutSeconds(timeoutSeconds); + $(Selectors.byText(text)) + .shouldBe(Condition.visible, Duration.ofSeconds(timeout)); + } + + /** + * Проверяет, что на странице отсутствует текст + */ + public static void textAbsentOnPage(String text, Integer timeoutSeconds) { + int timeout = getTimeoutSeconds(timeoutSeconds); + $(Selectors.byText(text)) + .shouldBe(Condition.not(Condition.visible), Duration.ofSeconds(timeout)); + } + + /** + * Проверяет, что на странице отсутствует элемент + */ + public static void elementAbsentOnPage(SelenideElement element, Integer timeoutSeconds) { + int timeout = getTimeoutSeconds(timeoutSeconds); + element + .shouldBe(Condition.not(Condition.visible), Duration.ofSeconds(timeout)); + } + + /** + * Проверяет, что текст элемента соответствует ожидаемому тексту + * + * @param element элемент + * @param expectedText ожидаемый текст + * @param timeoutSeconds количество секунд, в течении этого времени ожидается текст + */ + public static void elementTextEqualsExpectedText(SelenideElement element, String expectedText, Integer timeoutSeconds) { + int timeout = getTimeoutSeconds(timeoutSeconds); + element.shouldBe(Condition.exactTextCaseSensitive(expectedText), Duration.ofSeconds(timeout)); + } + + /** + * проверяет что текст элемента не соответствует ожидаемому тексту + * + * @param element элемент + * @param expectedText текст + * @param timeoutSeconds количество секунд + */ + public static void elementTextNotEqualsExpectedText(SelenideElement element, String expectedText, Integer timeoutSeconds) { + int timeout = getTimeoutSeconds(timeoutSeconds); + element.shouldNotBe(Condition.exactTextCaseSensitive(expectedText), Duration.ofSeconds(timeout)); + } + + /** + * Проверяет, что текст элемента содержит ожидаемый текст + * + * @param element элемент + * @param expectedText ожидаемый текст или регулярное выражение + * @param timeoutSeconds количество секунд, в течении этого времени ожидается текст + */ + public static void elementTextContainsExpectedText(SelenideElement element, String expectedText, Integer timeoutSeconds) { + int timeout = getTimeoutSeconds(timeoutSeconds); + element.shouldBe(Condition.matchText(expectedText), Duration.ofSeconds(timeout)); + } + + /** + * Проверяет, появился ли элемент за переданное время + * + * @param element - веб-элемент + * @param timeoutSeconds - таймаут ожидания (может быть null, в таком случае будет использоваться параметр из конфига webdriver.timeoutSeconds ) + * @return - появился ли элемент + */ + public static boolean isElementWillAppear(SelenideElement element, Integer timeoutSeconds) { + try { + elementVisibleOnPage(element, timeoutSeconds); + return true; + } catch (ElementNotFound elementNotFound) { + return false; + } + } + + /** + * Проверяет, исчез ли элемент за переданное время + * + * @param element веб-элемент + * @param timeoutSeconds - таймаут ожидания (может быть null, в таком случае будет использоваться параметр из конфига webdriver.timeoutSeconds ) + * @return - исчез ли элемент + */ + public static boolean isElementWillDisappear(SelenideElement element, Integer timeoutSeconds) { + try { + elementAbsentOnPage(element, timeoutSeconds); + return true; + } catch (ElementShould elementShould) { + return false; + } + } + + /** + * Проверяет, исчез ли элемент за переданное время + * + * @param xpath - xpath веб-элемента + * @param timeoutSeconds - таймаут ожидания (может быть null, в таком случае будет использоваться параметр из конфига webdriver.timeoutSeconds ) + * @return - исчез ли элемент + */ + public static boolean isElementWillDisappear(String xpath, Integer timeoutSeconds) { + try { + SelenideElement element = $(Selectors.byXpath(xpath)); + elementAbsentOnPage(element, timeoutSeconds); + return true; + } catch (ElementShould elementShould) { + return false; + } + } +} diff --git a/src/main/java/ru/lanit/at/api/ApiRequest.java b/src/main/java/ru/lanit/at/api/ApiRequest.java new file mode 100644 index 0000000..3e3e748 --- /dev/null +++ b/src/main/java/ru/lanit/at/api/ApiRequest.java @@ -0,0 +1,131 @@ +package ru.lanit.at.api; + +import io.qameta.allure.Allure; +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.http.Method; +import io.restassured.response.Response; +import io.restassured.specification.RequestSpecification; +import org.aeonbits.owner.ConfigFactory; +import ru.lanit.at.api.listeners.RestAssuredCustomLogger; +import ru.lanit.at.api.models.RequestModel; +import ru.lanit.at.api.properties.RestConfigurations; +import ru.lanit.at.utils.FileUtil; +import ru.lanit.at.utils.JsonUtil; +import ru.lanit.at.utils.RegexUtil; + +import java.io.ByteArrayInputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static ru.lanit.at.utils.ContextHolder.replaceVarsIfPresent; + +/** + * Created by Sorokin Boris on 21.01.2022. + */ +public class ApiRequest { + private final static RestConfigurations CONFIGURATIONS = ConfigFactory.create(RestConfigurations.class, + System.getProperties(), + System.getenv()); + + private String baseUrl; + private String path; + private Method method; + private String body; + private String fullUrl; + private Response response; + + private RequestSpecBuilder builder; + + public ApiRequest(RequestModel requestModel) { + this.builder = new RequestSpecBuilder(); + + this.baseUrl = CONFIGURATIONS.getBaseUrl(); + this.path = replaceVarsIfPresent(requestModel.getPath()); + this.method = Method.valueOf(requestModel.getMethod()); + this.body = requestModel.getBody(); + this.fullUrl = replaceVarsIfPresent(requestModel.getUrl()); + + URI uri; + + if (!fullUrl.isEmpty()) { + uri = URI.create(fullUrl.replace(" ", "+")); + } else { + uri = URI.create(baseUrl); + builder.setBasePath(path); + } + + this.builder.setBaseUri(uri); + setBodyFromFile(); + addLoggingListener(); + } + + public Response getResponse() { + return response; + } + + /** + * Сеттит заголовки + */ + public void setHeaders(Map headers) { + headers.forEach((k, v) -> { + builder.addHeader(k, v); + }); + } + + /** + * Сеттит query-параметры + */ + public void setQuery(Map query) { + query.forEach((k, v) -> { + builder.addQueryParam(k, v); + }); + } + + /** + * Отправляет сформированный запрос + */ + public void sendRequest() { + RequestSpecification requestSpecification = builder.build(); + + Response response = given() + .spec(requestSpecification) + .request(method); + + attachRequestResponseToAllure(response, body); + this.response = response; + } + + /** + * Сессит тело запроса из файла + */ + private void setBodyFromFile() { + if (body != null && RegexUtil.getMatch(body, ".*\\.json")) { + body = replaceVarsIfPresent(FileUtil.readBodyFromJsonDir(body)); + builder.setBody(body); + } + } + + /** + * Аттачит тело запроса и тело ответа в шаг отправки запроса + */ + private void attachRequestResponseToAllure(Response response, String requestBody) { + if (requestBody != null) { + Allure.addAttachment( + "Request", + "application/json", + new ByteArrayInputStream(requestBody.getBytes(StandardCharsets.UTF_8)), + ".txt"); + } + String responseBody = JsonUtil.jsonToUtf(response.body().asPrettyString()); + Allure.addAttachment("Response", "application/json", responseBody, ".txt"); + } + + /** + * Добавляет логгер, печатающий в консоль данные запросов и ответов + */ + private void addLoggingListener() { + builder.addFilter(new RestAssuredCustomLogger()); + } +} diff --git a/src/main/java/ru/lanit/at/api/listeners/RestAssuredCustomLogger.java b/src/main/java/ru/lanit/at/api/listeners/RestAssuredCustomLogger.java new file mode 100644 index 0000000..9276d8f --- /dev/null +++ b/src/main/java/ru/lanit/at/api/listeners/RestAssuredCustomLogger.java @@ -0,0 +1,63 @@ +package ru.lanit.at.api.listeners; + +import io.restassured.filter.Filter; +import io.restassured.filter.FilterContext; +import io.restassured.filter.log.UrlDecoder; +import io.restassured.response.Response; +import io.restassured.specification.FilterableRequestSpecification; +import io.restassured.specification.FilterableResponseSpecification; + +import java.nio.charset.Charset; + +import static ru.lanit.at.utils.ContextHolder.replaceVarsIfPresent; + + +/** + * Created by Sorokin Boris on 21.01.2022. + */ +public class RestAssuredCustomLogger implements Filter { + + @Override + public Response filter(FilterableRequestSpecification requestSpec + , FilterableResponseSpecification responseSpec + , FilterContext context) { + Response response = context.next(requestSpec, responseSpec); + + String uri = UrlDecoder.urlDecode(requestSpec.getURI(), + Charset.forName(requestSpec + .getConfig() + .getEncoderConfig() + .defaultQueryParameterCharset()), + true); + + StringBuilder stringBuilder = new StringBuilder(); + + stringBuilder.append("------------- REQUEST -------------\n"); + stringBuilder.append("URL: ") + .append(uri).append("\n") + .append("Method: ").append(requestSpec.getMethod()).append("\n"); + requestSpec.getHeaders().asList().forEach(header -> stringBuilder + .append("Header: ") + .append(replaceVarsIfPresent(header.getName())) + .append("=") + .append(replaceVarsIfPresent(header.getValue())) + .append("\n")); + requestSpec.getQueryParams().forEach((k, v) -> stringBuilder + .append("Query: ") + .append(replaceVarsIfPresent(k)) + .append("=") + .append(replaceVarsIfPresent(v)) + .append("\n")); + stringBuilder.append("Request Body: \n") + .append(replaceVarsIfPresent(requestSpec.getBody())) + .append("\n"); + stringBuilder.append("------------- RESPONSE -------------\n"); + stringBuilder.append("Status code: ") + .append(response.statusCode()) + .append("\n"); + stringBuilder.append("Response Body: \n") + .append(response.getBody().asPrettyString()); + System.out.println(stringBuilder); + return response; + } +} diff --git a/src/main/java/ru/lanit/at/api/models/RequestModel.java b/src/main/java/ru/lanit/at/api/models/RequestModel.java new file mode 100644 index 0000000..4ae131d --- /dev/null +++ b/src/main/java/ru/lanit/at/api/models/RequestModel.java @@ -0,0 +1,41 @@ +package ru.lanit.at.api.models; + +import com.google.gson.GsonBuilder; + +public class RequestModel { + private String method; + private String body; + private String path; + private String url; + + public RequestModel(String method, String body, String path, String url) { + this.method = method; + this.body = body; + this.path = path; + this.url = url; + } + + public String getMethod() { + return method; + } + + public String getBody() { + return body; + } + + public String getPath() { + return path; + } + + public String getUrl() { + return url; + } + + @Override + public String toString() { + return new GsonBuilder() + .setPrettyPrinting() + .create() + .toJson(this); + } +} diff --git a/src/main/java/ru/lanit/at/api/properties/RestConfigurations.java b/src/main/java/ru/lanit/at/api/properties/RestConfigurations.java new file mode 100644 index 0000000..5e86dc6 --- /dev/null +++ b/src/main/java/ru/lanit/at/api/properties/RestConfigurations.java @@ -0,0 +1,17 @@ +package ru.lanit.at.api.properties; + +import org.aeonbits.owner.Config; + +@Config.LoadPolicy(Config.LoadType.MERGE) +@Config.Sources({ + "classpath:config/configuration.properties", + "system:properties", + "system:env" +}) +public interface RestConfigurations extends Config { + + @Key("baseUrl") + @DefaultValue("") + String getBaseUrl(); + +} \ No newline at end of file diff --git a/src/main/java/ru/lanit/at/aspects/FragmentsAspect.java b/src/main/java/ru/lanit/at/aspects/FragmentsAspect.java new file mode 100644 index 0000000..f41eaef --- /dev/null +++ b/src/main/java/ru/lanit/at/aspects/FragmentsAspect.java @@ -0,0 +1,54 @@ +package ru.lanit.at.aspects; + +import io.cucumber.core.gherkin.Feature; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.testng.asserts.Assertion; +import ru.lanit.at.corecommonstep.fragment.FragmentReplacer; +import ru.lanit.at.corecommonstep.fragment.GherkinSerializer; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +@Aspect +public class FragmentsAspect { + + /** + * замена фрагментов и данных в фиче + * + * @param features список фич + * @param assertion assert + * @return список фич + * @throws IOException + * @throws IllegalAccessException + */ + public static List replaceSteps(List features, Assertion assertion) throws IOException, IllegalAccessException { + features = features.stream() + .filter(cucumberFeature -> cucumberFeature.getSource() != null) + .collect(Collectors.toList()); + + FragmentReplacer fragmentReplacer = new FragmentReplacer(features, assertion); + fragmentReplacer.replace(); + features = new GherkinSerializer().reserializeFeatures(features, assertion); + return features; + } + + @Pointcut("execution(* io.cucumber.core.runtime.FeaturePathFeatureSupplier.get(..))") + public void cucumberFeatures() { + } + + /** + * тут используется строгий assert + * + * @param joinPoint + * @return + * @throws Throwable + */ + @Around("cucumberFeatures()") + public Object replaceSteps(ProceedingJoinPoint joinPoint) throws Throwable { + return replaceSteps((List) joinPoint.proceed(), new Assertion()); + } +} diff --git a/src/main/java/ru/lanit/at/assertion/AssertErrorType.java b/src/main/java/ru/lanit/at/assertion/AssertErrorType.java new file mode 100644 index 0000000..311487a --- /dev/null +++ b/src/main/java/ru/lanit/at/assertion/AssertErrorType.java @@ -0,0 +1,17 @@ +package ru.lanit.at.assertion; + +public enum AssertErrorType { + + SOFT_ASSERT("SoftAssert"), + CRITICAL_ASSERT("CriticalAssert"); + + private String name; + + AssertErrorType(String name) { + this.name = name; + } + + public String getName() { + return name; + } +} \ No newline at end of file diff --git a/src/main/java/ru/lanit/at/assertion/AssertsManager.java b/src/main/java/ru/lanit/at/assertion/AssertsManager.java new file mode 100644 index 0000000..ba6115c --- /dev/null +++ b/src/main/java/ru/lanit/at/assertion/AssertsManager.java @@ -0,0 +1,62 @@ +package ru.lanit.at.assertion; + + +import org.aeonbits.owner.ConfigFactory; +import ru.lanit.at.utils.web.properties.Configurations; + +public class AssertsManager { + private static final ThreadLocal assertsManagerThreadLocal = new ThreadLocal<>(); + private static final Configurations conf = ConfigFactory.create(Configurations.class, System.getProperties(), + System.getenv()); + private ExtendedAssert asserts; + + public synchronized static AssertsManager getAssertsManager() { + AssertsManager localInstance = assertsManagerThreadLocal.get(); + if (localInstance == null) { + synchronized (AssertsManager.class) { + localInstance = assertsManagerThreadLocal.get(); + if (localInstance == null) { + AssertsManager assertsManager = new AssertsManager(); + assertsManager.setAsserts(new ExtendedAssert()); + assertsManagerThreadLocal.set(assertsManager); + localInstance = assertsManagerThreadLocal.get(); + } + } + } + return localInstance; + + } + + + /** + * Method to get {@link ExtendedAssert} previously marked as critical. So such assert will fail all test. + * + * @return Critical {@link ExtendedAssert}. + */ + public ExtendedAssert criticalAssert() { + asserts.setCritical(); + return asserts; + } + + /** + * Method to get soft {@link ExtendedAssert} that collects assertions. + * + * @return Soft {@link ExtendedAssert}. + */ + public ExtendedAssert softAssert() { + return asserts; + } + + /** + * Recreates instance of {@link ExtendedAssert}. + */ + public void flushAsserts() { + asserts.flush(); + } + + private void setAsserts(ExtendedAssert asserts) { + this.asserts = asserts; + } + + +} diff --git a/src/main/java/ru/lanit/at/assertion/ExtendedAssert.java b/src/main/java/ru/lanit/at/assertion/ExtendedAssert.java new file mode 100644 index 0000000..3fbd7ce --- /dev/null +++ b/src/main/java/ru/lanit/at/assertion/ExtendedAssert.java @@ -0,0 +1,69 @@ +package ru.lanit.at.assertion; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testng.asserts.IAssert; +import org.testng.asserts.SoftAssert; +import org.testng.collections.Maps; +import ru.lanit.at.utils.allure.AllureHelper; + +import java.util.Map; + + +public class ExtendedAssert extends SoftAssert { + private final static Logger LOGGER = LoggerFactory.getLogger(ExtendedAssert.class); + private final Map> m_errors = Maps.newLinkedHashMap(); + private Boolean isCritical = false; + + + public void setCritical() { + isCritical = true; + } + + + @Override + protected void doAssert(IAssert a) { + onBeforeAssert(a); + try { + a.doAssert(); + onAssertSuccess(a); + LOGGER.debug("Успешно проверено: [{}]", a.getActual()); + } catch (AssertionError ex) { + LOGGER.error(ex.getMessage()); + onAssertFailure(a, ex); + m_errors.put(ex, a); + AllureHelper.setStepStatusBroken("SoftAssert:" + ex.getMessage()); + if (isCritical) { + this.assertAll(); + } + } finally { + this.isCritical = false; + onAfterAssert(a); + } + } + + @Override + public void assertAll() { + if (!m_errors.isEmpty()) { + StringBuilder sb = new StringBuilder("The following asserts failed:"); + boolean first = true; + for (Map.Entry> ae : m_errors.entrySet()) { + if (first) { + first = false; + } else { + sb.append(","); + } + sb.append("\n\t"); + sb.append(ae.getKey().getMessage()); + ae.getKey().printStackTrace(); + } + if (isCritical) sb.append(" [BLOCKER]"); + m_errors.clear(); + throw new AssertionError(sb.toString()); + } + } + + public void flush() { + m_errors.clear(); + } +} diff --git a/src/main/java/ru/lanit/at/corecommonstep/CommonSteps.java b/src/main/java/ru/lanit/at/corecommonstep/CommonSteps.java new file mode 100644 index 0000000..13ec927 --- /dev/null +++ b/src/main/java/ru/lanit/at/corecommonstep/CommonSteps.java @@ -0,0 +1,23 @@ +package ru.lanit.at.corecommonstep; + +import io.cucumber.java.ru.И; +import ru.lanit.at.corecommonstep.fragment.FragmentReplacer; + + +public class CommonSteps { + + @И(FragmentReplacer.REGEX_FRAGMENT) + public void userInsertsFragment(String fragmentName) { + throw new IllegalStateException("фрагмент не подставился"); + } + + /** + * Шаг-заглушка для отчета аллюра и группировки шагов фрагмента под спойлер + * Если будет использоваться в тесте - будет assert "надо использовать другой шаг" + * @param fragmentName - название фрагмента (сценария) + */ + @И(FragmentReplacer.REGEX_FRAGMENT_SPOILER) + public void fragment(String fragmentName) { + } + +} diff --git a/src/main/java/ru/lanit/at/corecommonstep/fragment/CustomGherkinMessagesDataTableArgument.java b/src/main/java/ru/lanit/at/corecommonstep/fragment/CustomGherkinMessagesDataTableArgument.java new file mode 100644 index 0000000..95f8721 --- /dev/null +++ b/src/main/java/ru/lanit/at/corecommonstep/fragment/CustomGherkinMessagesDataTableArgument.java @@ -0,0 +1,27 @@ +package ru.lanit.at.corecommonstep.fragment; + +import io.cucumber.core.gherkin.DataTableArgument; + +import java.util.List; + +/* пришлось написать свою реализацию класса io.cucumber.core.gherkin.messages.GherkinMessagesDataTableArgument */ +public class CustomGherkinMessagesDataTableArgument implements DataTableArgument { + + private List> cells; + private int line; + + public CustomGherkinMessagesDataTableArgument(List> cells, int line) { + this.cells = cells; + this.line = line; + } + + @Override + public List> cells() { + return cells; + } + + @Override + public int getLine() { + return line; + } +} diff --git a/src/main/java/ru/lanit/at/corecommonstep/fragment/CustomGherkinMessagesDocStringArgument.java b/src/main/java/ru/lanit/at/corecommonstep/fragment/CustomGherkinMessagesDocStringArgument.java new file mode 100644 index 0000000..0d0819e --- /dev/null +++ b/src/main/java/ru/lanit/at/corecommonstep/fragment/CustomGherkinMessagesDocStringArgument.java @@ -0,0 +1,38 @@ +package ru.lanit.at.corecommonstep.fragment; + +import io.cucumber.core.gherkin.DocStringArgument; + +public class CustomGherkinMessagesDocStringArgument implements DocStringArgument { + + private String content; + private String contentType; + private String mediaType; + private int line; + + public CustomGherkinMessagesDocStringArgument(String content, String contentType, String mediaType, int line) { + this.content = content; + this.contentType = contentType; + this.mediaType = mediaType; + this.line = line; + } + + @Override + public String getContent() { + return content; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public String getMediaType() { + return mediaType; + } + + @Override + public int getLine() { + return line; + } +} diff --git a/src/main/java/ru/lanit/at/corecommonstep/fragment/CustomStep.java b/src/main/java/ru/lanit/at/corecommonstep/fragment/CustomStep.java new file mode 100644 index 0000000..9876d05 --- /dev/null +++ b/src/main/java/ru/lanit/at/corecommonstep/fragment/CustomStep.java @@ -0,0 +1,116 @@ +package ru.lanit.at.corecommonstep.fragment; + +import io.cucumber.core.gherkin.Argument; +import io.cucumber.core.gherkin.Step; +import io.cucumber.core.gherkin.StepType; +import io.cucumber.gherkin.GherkinDialect; +import io.cucumber.plugin.event.DataTableArgument; +import io.cucumber.plugin.event.DocStringArgument; +import io.cucumber.plugin.event.Location; + +import java.util.List; + + +public class CustomStep implements Step { + + private final Argument argument; + private final String keyWord; + private final StepType stepType; + private final String previousGwtKeyWord; + private final Location location; + private final String id; + private final String text; + + public CustomStep( + String id, + String text, + List> argument, + Class argumentType, + GherkinDialect dialect, + String previousGwtKeyWord, + Location location, + String keyword + ) { + this.id = id; + this.text = text; + this.argument = extractArgument(argument, argumentType, location); + this.keyWord = keyword; + this.stepType = extractKeyWordType(keyWord, dialect); + this.previousGwtKeyWord = previousGwtKeyWord; + this.location = location; + } + + private static Argument extractArgument(List> argument, Class argumentType, Location location) { + if (!argument.isEmpty()) { + if (DataTableArgument.class.equals(argumentType)) { + return new CustomGherkinMessagesDataTableArgument(argument, location.getLine() + 1); + } else if (DocStringArgument.class.equals(argumentType)) { + return new CustomGherkinMessagesDocStringArgument(argument.get(0).get(0), "", "", location.getLine() + 1); + } + throw new IllegalStateException(String.format("Неожиданный тип значения: %s.\n", argumentType.getName())); + } + return null; + } + + private static StepType extractKeyWordType(String keyWord, GherkinDialect dialect) { + if (StepType.isAstrix(keyWord)) { + return StepType.OTHER; + } + if (dialect.getGivenKeywords().contains(keyWord)) { + return StepType.GIVEN; + } + if (dialect.getWhenKeywords().contains(keyWord)) { + return StepType.WHEN; + } + if (dialect.getThenKeywords().contains(keyWord)) { + return StepType.THEN; + } + if (dialect.getAndKeywords().contains(keyWord)) { + return StepType.AND; + } + if (dialect.getButKeywords().contains(keyWord)) { + return StepType.BUT; + } + throw new IllegalStateException("Keyword " + keyWord + " was neither given, when, then, and, but nor *"); + } + + @Override + public StepType getType() { + return stepType; + } + + @Override + public String getPreviousGivenWhenThenKeyword() { + return previousGwtKeyWord; + } + + @Override + public String getId() { + return id; + } + + @Override + public Argument getArgument() { + return argument; + } + + @Override + public String getKeyword() { + return keyWord; + } + + @Override + public String getText() { + return text; + } + + @Override + public int getLine() { + return location.getLine(); + } + + @Override + public Location getLocation() { + return new Location(location.getLine(), location.getColumn()); + } +} diff --git a/src/main/java/ru/lanit/at/corecommonstep/fragment/FragmentReplacer.java b/src/main/java/ru/lanit/at/corecommonstep/fragment/FragmentReplacer.java new file mode 100644 index 0000000..2e7e228 --- /dev/null +++ b/src/main/java/ru/lanit/at/corecommonstep/fragment/FragmentReplacer.java @@ -0,0 +1,425 @@ +package ru.lanit.at.corecommonstep.fragment; + +import com.google.common.graph.EndpointPair; +import com.google.common.graph.MutableValueGraph; +import com.google.common.graph.ValueGraphBuilder; +import io.cucumber.core.feature.FeatureParser; +import io.cucumber.core.feature.FeatureWithLines; +import io.cucumber.core.gherkin.Feature; +import io.cucumber.core.gherkin.Pickle; +import io.cucumber.core.gherkin.Step; +import io.cucumber.gherkin.GherkinDialect; +import io.cucumber.gherkin.GherkinDialectProvider; +import io.cucumber.gherkin.IGherkinDialectProvider; +import io.cucumber.plugin.event.DataTableArgument; +import io.cucumber.plugin.event.DocStringArgument; +import io.cucumber.plugin.event.Location; +import org.apache.commons.lang3.reflect.FieldUtils; +import org.testng.asserts.Assertion; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + + +public class FragmentReplacer { + + public static final String NOT_FOUND_VARS_FOR_DYNAMIC_FRAGMENTS = "Следующие переменные не были переданы в динамический фрагмент из сценария '%s' для шага '%s': %s\n"; + public static final String FOUND_MORE_ONE_FRAGMENT = "Найдено более одного фрагмента с наименованием и путем:\n%s"; + public static final String FRAGMENT_NOT_EXIST = "Нет такого фрагмента с названием - '%s'."; + public static final String LANGUAGE_IS_NOT_SAME = "Язык фрагмента не совпадает с языком сценария! Сценарий: '%s', Фрагмент: '%s'"; + + public static final String REGEX_FRAGMENT = "^вызвать фрагмент \"(.+)\"$"; + public static final String REGEX_FRAGMENT_SPOILER = "^ФРАГМЕНТ \"(.+)\"$"; + public static final String FEATURE_SUFFIX = ".feature"; + public static final String FRAGMENT_TAG = "@fragment"; + private static final String REGEX_VALUE = "<%s>"; + private static final String REGEX_EXAMPLE = "<(.*)>"; + + private List features; + private Map scenarioLanguageMap; + private MutableValueGraph fragmentsGraph; + private Assertion assertion; + + public FragmentReplacer(List features, Assertion assertion) throws IOException { + this.assertion = assertion; + this.features = cacheFragmentsToFeatures(features); + this.scenarioLanguageMap = cacheScenarioLanguage(this.features); + Map fragmentsMap = cacheFragmentsAsMap(this.features); + this.fragmentsGraph = cacheFragmentsAsGraph(this.features, fragmentsMap, this.scenarioLanguageMap); + } + + public static Boolean getMatch(String sentence, String regex) { + if (sentence != null && regex != null) { + Pattern pattern = Pattern.compile(regex); + Matcher matcher = pattern.matcher(sentence); + return matcher.find(); + } + return null; + } + + public static String getMatchValueByGroupNumber(String sentence, String regex, int groupNumber) { + if (sentence != null && regex != null) { + sentence = sentence.trim(); + Pattern pattern = Pattern.compile(regex); + Matcher matcher = pattern.matcher(sentence); + if (matcher.find()) { + return matcher.group(groupNumber); + } + } + return null; + } + + public void replace() throws IllegalAccessException { + while (!fragmentsGraph.edges().isEmpty()) { + int fragmentsGraphSize = fragmentsGraph.edges().size(); + + for (EndpointPair edge : new ArrayList<>(fragmentsGraph.edges())) { + Pickle fragment = (Pickle) edge.nodeV(); + Pickle scenario = (Pickle) edge.nodeU(); + + if (isTerminal(fragment)) { + replaceFragmentInScenario(scenario, fragment); + fragmentsGraph.removeEdge(scenario, fragment); + } + } + + if (fragmentsGraphSize == fragmentsGraph.edges().size()) { + throw new AssertionError("Fragments replacing is no longer performed, it will lead to an infinite loop. Interrupting..."); + } + } + } + + /** + * загрузка фича файлов из папки с фрагментами + * + * @return лист Feature файлов + * @throws IOException + */ + private List cacheFragmentsToFeatures(List features) throws IOException { + List featurePaths = new ArrayList(); + StringBuilder pathBuilder = getParentPath("fragments"); + + List collect = Files.walk(Paths.get(pathBuilder.toString())) + .filter(p -> p.toAbsolutePath().toString().endsWith(FEATURE_SUFFIX)) + .collect(Collectors.toList()); + collect.forEach(p -> { + featurePaths.add(FeatureWithLines.create(p.toUri(), Collections.emptyList())); + }); + + List uriList = featurePaths.stream().map(p -> p.uri()).collect(Collectors.toList()); + List loadedFeaturesList = new ArrayList<>(); + FeatureParser featureParser = new FeatureParser(() -> UUID.randomUUID()); + + Iterator iterator = uriList.iterator(); + while (iterator.hasNext()) { + URI uri = iterator.next(); + StringBuilder stringBuilder = new StringBuilder(); + Files.readAllLines(Paths.get(uri)).forEach(p -> stringBuilder.append(p).append("\n")); + GherkinResource gherkinResource = new GherkinResource(stringBuilder.toString(), uri); + Optional feature = featureParser.parseResource(gherkinResource); + feature.ifPresent(loadedFeaturesList::add); + } + + return Stream.concat(features.stream(), loadedFeaturesList.stream()).collect(Collectors.toList()); + } + + private Map cacheScenarioLanguage(List features) { + Map scenarioLanguageMap = new HashMap<>(); + for (Feature feature : features) { + List pickles = feature.getPickles(); + for (Pickle pickle : pickles) { + scenarioLanguageMap.put(pickle, pickle.getLanguage()); + } + } + return scenarioLanguageMap; + } + + private Map cacheFragmentsAsMap(List features) { + Map fragments = new HashMap<>(); + + List collect = features.stream().map(Feature::getPickles).flatMap(List::stream).collect(Collectors.toList()); + + for (Pickle pickle : collect) { + if (isFragmentTagContains(pickle.getTags())) { + List pickles = collect.stream() + .filter(p -> isFragmentTagContains(p.getTags())) + .filter(p -> p.getName().equalsIgnoreCase(pickle.getName())) + .collect(Collectors.toList()); + if (pickles.size() > 1) { + StringBuilder stringBuilder = new StringBuilder(); + pickles.forEach(p -> stringBuilder.append(p.getName()).append(", ").append(p.getUri().getPath()).append(";\n")); + assertion.fail(String.format(FOUND_MORE_ONE_FRAGMENT, stringBuilder.toString())); + } + } + } + + for (Feature feature : features) { + List pickles = feature.getPickles(); + for (Pickle pickle : pickles) { + List tags = pickle.getTags(); + if (isFragmentTagContains(tags)) { + fragments.put(pickle.getName(), pickle); + } + } + } + + return fragments; + } + + private MutableValueGraph cacheFragmentsAsGraph(List features, + Map fragmentsMap, + Map scenarioLanguageMap) { + MutableValueGraph graph = ValueGraphBuilder.directed().allowsSelfLoops(false).build(); + + for (Feature feature : features) { + List pickleList = feature.getPickles().stream().filter(pickle1 -> isScenario(pickle1)).collect(Collectors.toList()); + + for (Pickle pickle : pickleList) { + addGraphNode(graph, pickle, fragmentsMap, scenarioLanguageMap); + } + } + + return graph; + } + + private void addGraphNode(MutableValueGraph graph, + Pickle scenario, + Map fragmentsMap, + Map scenarioLanguageMap) { + graph.addNode(scenario); + String scenarioLanguage = scenarioLanguageMap.get(scenario); + List steps = scenario.getSteps(); + + for (Step step : steps) { + if (getMatch(step.getText(), REGEX_FRAGMENT_SPOILER)) { + assertion.fail(String.format("Для использования фрагментов используйте другой шаг: %s\n Путь к фича-файлу: %s", REGEX_FRAGMENT, scenario.getUri().getPath())); + } + String fragmentName = getFragmentName(step); + if (fragmentName != null) { + String fragmentNameFromMap = fragmentsMap.keySet().stream() + .filter(key -> key.equalsIgnoreCase(fragmentName)) + .findAny().orElse(null); + Pickle pickle = fragmentsMap.get(fragmentNameFromMap); + + if (pickle == null) { + // пофиг на данные @data + assertion.fail(String.format(FRAGMENT_NOT_EXIST, fragmentName)); + } else { + String fragmentLanguage = pickle.getLanguage(); + assertion.assertEquals(scenarioLanguage, fragmentLanguage, String.format(LANGUAGE_IS_NOT_SAME, scenarioLanguage, fragmentLanguage)); + + graph.putEdgeValue(scenario, pickle, ""); + + addGraphNode(graph, pickle, fragmentsMap, scenarioLanguageMap); + } + } + } + } + + private boolean isFragmentTagContains(List tags) { + return tags.stream().anyMatch(tag -> tag.equals(FRAGMENT_TAG)); + } + + private boolean isScenario(Pickle pickle) { + List tags = pickle.getTags(); + return tags.stream().noneMatch(tag -> tag.equals(FRAGMENT_TAG)); + } + + private boolean isTerminal(Object node) { + for (EndpointPair edge : fragmentsGraph.edges()) { + if (edge.nodeU().equals(node)) { + return false; + } + } + + return true; + } + + private void replaceFragmentInScenario(Pickle scenario, Pickle fragment) throws IllegalAccessException { + List replacementSteps = new ArrayList<>(); + + for (Step step : scenario.getSteps()) { + String fragmentName = getFragmentName(step); + if (fragmentName != null && !fragmentName.isEmpty() && fragmentName.equalsIgnoreCase(fragment.getName())) { + Step mockStep = getMockStep(step, fragment); + replacementSteps.add(mockStep); + replacementSteps.addAll(replaceSteps(step, fragment.getSteps(), fragment.getLanguage(), scenario)); + replacementSteps.add(mockStep); + } else { + replacementSteps.add(step); + } + } + FieldUtils.writeField(scenario, "steps", replacementSteps, true); + } + + /** + * Генерирует шаг-заглушки, который в аллюр отчете подменяется на спойлер + * @param step - шаг "вызвать фрагмент" + * @param fragment - сценарий-фрагмент + * @return - шаг ФРАГМЕНТ "название фрагмента" + */ + private Step getMockStep(Step step, Pickle fragment) { + IGherkinDialectProvider dialectProvider = new GherkinDialectProvider(); + GherkinDialect dialect = dialectProvider.getDialect(fragment.getLanguage(), null); + Location location = step.getLocation(); + return new CustomStep( + step.getId(), + String.format("ФРАГМЕНТ \"%s\"", fragment.getName()), + new ArrayList<>(), + Object.class, + dialect, + step.getPreviousGivenWhenThenKeyword(), + new Location(location.getLine(), location.getColumn()), + step.getKeyword()); + } + + private String getFragmentName(Step step) { + return getMatchValueByGroupNumber(step.getText(), REGEX_FRAGMENT, 1); + } + + private List replaceSteps(Step scenarioStep, List fragmentsSteps, String language, Pickle scenario) { + DataTableArgument argument = (DataTableArgument) scenarioStep.getArgument(); + List replacementSteps = new ArrayList<>(); + + if (argument != null) { + Class argumentType = Object.class; + List> lines = argument.cells(); + checkArguments(lines, scenario); + for (Step fragmentStep : fragmentsSteps) { + List> replaceFragmentLines = new ArrayList<>(); + if (fragmentStep.getArgument() instanceof DataTableArgument) { + argumentType = DataTableArgument.class; + /* замена данных в таблице если она есть у шага */ + DataTableArgument fragmentStepArgument = (DataTableArgument) fragmentStep.getArgument(); + if (fragmentStepArgument != null) { + for (List fragmentLines : fragmentStepArgument.cells()) { + for (List line : lines) { + String regex = String.format(REGEX_VALUE, line.get(0)); + fragmentLines = fragmentLines.stream().map(p -> p.replace(regex, line.get(1))).collect(Collectors.toList()); + } + StringBuilder values = new StringBuilder(); + for (String fragmentLine : fragmentLines) { + if (getMatch(fragmentLine, REGEX_EXAMPLE)) { + values.append(getMatchValueByGroupNumber(fragmentLine, REGEX_EXAMPLE, 1)).append(" "); + } + } + if (values.length() != 0) { + assertion.fail( + String.format(NOT_FOUND_VARS_FOR_DYNAMIC_FRAGMENTS, + scenario.getName(), + fragmentStep.getText(), + values.toString())); + } + replaceFragmentLines.add(fragmentLines); + } + } + } else if (fragmentStep.getArgument() instanceof DocStringArgument) { + argumentType = DocStringArgument.class; + /* замена данных в DocStringArgument если есть у шага */ + DocStringArgument fragmentStepArgument = (DocStringArgument) fragmentStep.getArgument(); + if (fragmentStepArgument != null) { + String content = fragmentStepArgument.getContent(); + for (List line : lines) { + String regex = String.format(REGEX_VALUE, line.get(0)); + content = content.replace(regex, line.get(1)); + } + checkVarsForDynamicFragments(content, scenario, fragmentStep); + replaceFragmentLines.add(Collections.singletonList(content)); + } + } + + /* замена в текстовке шага */ + String replaceValue = fragmentStep.getText(); + for (List line : lines) { + String regex = String.format(REGEX_VALUE, line.get(0)); + if (getMatch(fragmentStep.getText(), regex)) { + replaceValue = replaceValue.replace(regex, line.get(1)); + } + } + checkVarsForDynamicFragments(replaceValue, scenario, fragmentStep); + + IGherkinDialectProvider dialectProvider = new GherkinDialectProvider(); + GherkinDialect dialect = dialectProvider.getDialect(language, null); + Location location = fragmentStep.getLocation(); + Step replaceStep = new CustomStep( + fragmentStep.getId(), + replaceValue, + replaceFragmentLines, + argumentType, + dialect, + fragmentStep.getPreviousGivenWhenThenKeyword(), + new Location(location.getLine(), location.getColumn()), + fragmentStep.getKeyword()); + replacementSteps.add(replaceStep); + } + + return replacementSteps; + } else { + return fragmentsSteps; + } + } + + private void checkArguments(List> lines, Pickle scenario) { + lines.forEach(line -> + assertion.assertEquals( + line.size(), + 2, + String.format("Количество значений в таблице не равно двум. Актуальное значение: %s. Путь '%s', наименование сценария '%s'.", + line.size(), + scenario.getUri().getPath(), + scenario.getName() + ) + ) + ); + } + + private void checkVarsForDynamicFragments(String value, Pickle scenario, Step fragmentStep) { + if (getMatch(value, REGEX_EXAMPLE)) { + assertion.fail( + String.format(NOT_FOUND_VARS_FOR_DYNAMIC_FRAGMENTS, + scenario.getName(), + fragmentStep.getText(), + getMatchValueByGroupNumber(value, REGEX_EXAMPLE, 1))); + } + } + + /** + * метод для составления полного пути до директории или файла + * + * @param packages массив наименований папок или файла (в конце) + * @return полный путь до директории или файла + */ + private StringBuilder getParentPath(String... packages) { + StringBuilder pathBuilder = new StringBuilder(); + String separator = System.getProperty("file.separator"); + if (!System.getProperty("base.dir", "").isEmpty()) { + pathBuilder.append(System.getProperty("base.dir")); + } else { + pathBuilder.append(System.getProperty("user.dir")); + } + for (String packageName : packages) { + if (pathBuilder.toString().endsWith(separator)) { + pathBuilder.append(packageName); + } else { + pathBuilder.append(separator).append(packageName); + } + } + + return pathBuilder; + } +} diff --git a/src/main/java/ru/lanit/at/corecommonstep/fragment/GherkinResource.java b/src/main/java/ru/lanit/at/corecommonstep/fragment/GherkinResource.java new file mode 100644 index 0000000..546f004 --- /dev/null +++ b/src/main/java/ru/lanit/at/corecommonstep/fragment/GherkinResource.java @@ -0,0 +1,30 @@ +package ru.lanit.at.corecommonstep.fragment; + +import io.cucumber.core.resource.Resource; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.Charset; + +public class GherkinResource implements Resource { + + private final URI path; + private InputStream source; + + public GherkinResource(String source, URI path) { + this.source = new ByteArrayInputStream(source.getBytes(Charset.defaultCharset())); + this.path = path; + } + + @Override + public URI getUri() { + return path; + } + + @Override + public InputStream getInputStream() throws IOException { + return source; + } +} diff --git a/src/main/java/ru/lanit/at/corecommonstep/fragment/GherkinSerializer.java b/src/main/java/ru/lanit/at/corecommonstep/fragment/GherkinSerializer.java new file mode 100644 index 0000000..4430e37 --- /dev/null +++ b/src/main/java/ru/lanit/at/corecommonstep/fragment/GherkinSerializer.java @@ -0,0 +1,146 @@ +package ru.lanit.at.corecommonstep.fragment; + +import io.cucumber.core.feature.FeatureParser; +import io.cucumber.core.gherkin.Feature; +import io.cucumber.core.gherkin.Pickle; +import io.cucumber.core.gherkin.Step; +import io.cucumber.core.resource.Resource; +import io.cucumber.plugin.event.DataTableArgument; +import io.cucumber.plugin.event.DocStringArgument; +import org.testng.asserts.Assertion; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +public class GherkinSerializer { + + public static final String DATA_TAG = "@data=$"; + private static final String NL = "\n"; + private static final String SPACE = " "; + private StringBuilder builder; + private Assertion assertion; + + public GherkinSerializer() { + builder = new StringBuilder(); + } + + public List reserializeFeatures(List cucumberFeatures, Assertion assertion) { + this.assertion = assertion; + FeatureParser featureParser = new FeatureParser(() -> UUID.randomUUID()); + List parsedFeatures = new ArrayList<>(); + cucumberFeatures.forEach(cucumberFeature -> { + builder = new StringBuilder(); + List pickles = cucumberFeature.getPickles(); + if (pickles.size() > 0) { + builder.append("#language: " + pickles.get(0).getLanguage()); + } else { + this.assertion.fail(String.format("\nПроверьте синтаксис фича-файла!:\n%s\n", cucumberFeature.getUri())); + } + nl(1); + Optional featureKeyword = cucumberFeature.getKeyword(); + Optional featureName = cucumberFeature.getName(); + if (featureKeyword.isPresent()) { + builder.append(featureKeyword.get()).append(":").append(SPACE); + } else { + this.assertion.fail("\"Нет кейворда Функционал!\""); + } + + featureName.ifPresent(name -> builder.append(name)); + nl(1); + pickles.forEach(this::buildScenario); + + Resource gherkinResource = new GherkinResource(builder.toString(), cucumberFeature.getUri()); + Optional feature = featureParser.parseResource(gherkinResource); + feature.ifPresent(parsedFeatures::add); + }); + + return parsedFeatures; + } + + private void buildScenario(Pickle pickle) { + buildScenarioTags(pickle); + + tab(1); + builder.append(pickle.getKeyword()).append(":").append(SPACE).append(pickle.getName()); + nl(1); + + List dataTags = pickle.getTags().stream().filter(p -> p.startsWith(DATA_TAG)).collect(Collectors.toList()); + if (!dataTags.isEmpty() && dataTags.size() > 1) { + assertion.fail(String.format("Есть два тега '%s' в сценарии '%s'. Необходимо указать только один тег для данных!\n", dataTags.toString(), pickle.getName())); + } + + if (!dataTags.isEmpty()) { + pickle.getSteps().forEach(step -> buildStep(step, dataTags.get(0), pickle)); + } else { + pickle.getSteps().forEach(step -> buildStep(step, null, pickle)); + } + } + + private void buildScenarioTags(Pickle scenarioDefinition) { + List tags = scenarioDefinition.getTags(); + if (!tags.isEmpty()) { + tags = tags.stream().filter(p -> !p.startsWith(DATA_TAG)).collect(Collectors.toList()); + tab(1); + tags.forEach(tag -> builder.append(tag).append(SPACE)); + nl(1); + } + } + + private void buildStep(Step step, String dataTag, Pickle scenario) { + tab(2); + builder.append(step.getKeyword()).append(SPACE).append(step.getText()); + if (step.getArgument() != null) { + nl(1); + if (step.getArgument() instanceof DataTableArgument) { + DataTableArgument table = (DataTableArgument) step.getArgument(); + table.cells().forEach(tableRow -> buildTableRow(tableRow, dataTag, scenario)); + } else if (step.getArgument() instanceof DocStringArgument) { + tab(2); + builder.append("\"\"\""); + nl(1); + DocStringArgument docString = (DocStringArgument) step.getArgument(); + tab(2); + builder.append(docString.getContent()); + nl(1); + tab(2); + builder.append("\"\"\""); + nl(1); + } + + } else { + builder.append("\n"); + } + } + + private void buildTableRow(List tableRow, String dataTag, Pickle scenario) { + List collect = tableRow.stream() + .map(tableCell -> tableCell.replaceAll("\\|", "\\\\|")) + .collect(Collectors.toList()); + tab(2); + space(2); + builder.append("|").append(String.join("|", collect)).append("|"); + nl(1); + } + + + private void space(int count) { + appendTimes(SPACE, count); + } + + private void nl(int count) { + appendTimes(NL, count); + } + + private void tab(int count) { + appendTimes(SPACE, count * 2); + } + + private void appendTimes(String source, int times) { + for (int i = 0; i < times; i++) { + builder.append(source); + } + } +} diff --git a/src/main/java/ru/lanit/at/hooks/WebHooks.java b/src/main/java/ru/lanit/at/hooks/WebHooks.java new file mode 100644 index 0000000..76d0e35 --- /dev/null +++ b/src/main/java/ru/lanit/at/hooks/WebHooks.java @@ -0,0 +1,50 @@ +package ru.lanit.at.hooks; + +import com.codeborne.selenide.WebDriverRunner; +import io.cucumber.java.After; +import io.cucumber.java.Before; +import io.cucumber.java.Scenario; +import org.aeonbits.owner.ConfigFactory; +import org.openqa.selenium.remote.RemoteWebDriver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import ru.lanit.at.utils.VideoSaveHelper; +import ru.lanit.at.utils.web.properties.Configurations; + +import static ru.lanit.at.assertion.AssertsManager.getAssertsManager; + +public class WebHooks { + private static final Logger LOGGER = LoggerFactory.getLogger(WebHooks.class); + + @Before + public void setup(Scenario scenario) { + } + + @After + public void close() { + if (WebDriverRunner.hasWebDriverStarted()) { + String sessionId = ((RemoteWebDriver) WebDriverRunner.getWebDriver()).getSessionId().toString(); + LOGGER.info("Закрытие сессии драйвера"); + WebDriverRunner.closeWebDriver(); + attachVideo(sessionId); + } + + getAssertsManager().softAssert().assertAll(); + getAssertsManager().softAssert().flush(); + } + + + /** Прикрепление видео в Аллюр отчет при условии запускать с параметром remoteUrl. */ + private void attachVideo(String sessionId) { + Configurations cf = ConfigFactory.create(Configurations.class + , System.getProperties(), + System.getenv()); + if (!cf.getRemoteURL().isEmpty() && cf.getEnableVideo()) { + VideoSaveHelper vs = new VideoSaveHelper(sessionId, cf.getRemoteURL()); + vs.attachVideoFileRest(); + vs.deleteSelenoidVideoRest(); + } + } + + +} diff --git a/src/main/java/ru/lanit/at/pages/GooglePage.java b/src/main/java/ru/lanit/at/pages/GooglePage.java new file mode 100644 index 0000000..e4c64aa --- /dev/null +++ b/src/main/java/ru/lanit/at/pages/GooglePage.java @@ -0,0 +1,17 @@ +package ru.lanit.at.pages; + +import com.codeborne.selenide.SelenideElement; +import ru.lanit.at.utils.web.annotations.Name; +import ru.lanit.at.utils.web.pagecontext.WebPage; + +import static com.codeborne.selenide.Selenide.$x; + +@Name(value = "Google") +public class GooglePage extends WebPage { + + @Name("поле поиска") + private SelenideElement searchField = $x("//input[@name='q']"); + + @Name("кнопка поиска") + private SelenideElement searchButton = $x("//input[@value='Поиск в Google']"); +} diff --git a/src/main/java/ru/lanit/at/pages/GoogleResultPage.java b/src/main/java/ru/lanit/at/pages/GoogleResultPage.java new file mode 100644 index 0000000..fdc2121 --- /dev/null +++ b/src/main/java/ru/lanit/at/pages/GoogleResultPage.java @@ -0,0 +1,15 @@ +package ru.lanit.at.pages; + +import com.codeborne.selenide.SelenideElement; +import org.openqa.selenium.By; +import ru.lanit.at.utils.web.annotations.Name; +import ru.lanit.at.utils.web.pagecontext.WebPage; + +import static com.codeborne.selenide.Selenide.$; + +@Name(value = "Google страница результатов") +public class GoogleResultPage extends WebPage { + + @Name("виджет погоды") + private SelenideElement searchField = $(By.id("wob_wc")); +} \ No newline at end of file diff --git a/src/main/java/ru/lanit/at/steps/VariableSteps.java b/src/main/java/ru/lanit/at/steps/VariableSteps.java new file mode 100644 index 0000000..9538003 --- /dev/null +++ b/src/main/java/ru/lanit/at/steps/VariableSteps.java @@ -0,0 +1,51 @@ +package ru.lanit.at.steps; + + +import io.cucumber.java.ru.И; +import io.cucumber.java.ru.Когда; +import io.qameta.allure.Allure; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import ru.lanit.at.utils.ContextHolder; +import ru.lanit.at.utils.DataGenerator; + +import java.util.Map; + +import static ru.lanit.at.utils.ContextHolder.replaceVarsIfPresent; + +public class VariableSteps { + private Logger LOGGER = LogManager.getLogger(this.getClass()); + + /** + * Сгенерировать и Сохранить значение + * + * @param field - наименование элемента + * @param key - значение + */ + @Когда("сгенерировать значение {string} и сохранить под именем {string}") + public void generateAndSaveValue(String field, String key) { + String value = DataGenerator.generateValueByMask(field); + ContextHolder.asMap().put(key, value); + LOGGER.info("значение '{}' сохранено под именем '{}'", value, key); + } + + @И("сгенерировать переменные") + public void generateVariables(Map table) { + table.forEach((k, v) -> { + String value = DataGenerator.generateValueByMask(replaceVarsIfPresent(v)); + ContextHolder.put(k, value); + Allure.addAttachment(k, "application/json", k + ": " + value, ".txt"); + LOGGER.info("Сгенерирована переменная: {}={}", k, value); + }); + } + + @И("создать контекстные переменные") + public void createContextVariables(Map table) { + table.forEach((k, v) -> { + ContextHolder.put(k, v); + LOGGER.info("Сохранена переменная: {}={}", k, v); + }); + } + + +} diff --git a/src/main/java/ru/lanit/at/steps/api/ApiSteps.java b/src/main/java/ru/lanit/at/steps/api/ApiSteps.java new file mode 100644 index 0000000..cb4f521 --- /dev/null +++ b/src/main/java/ru/lanit/at/steps/api/ApiSteps.java @@ -0,0 +1,80 @@ +package ru.lanit.at.steps.api; + + +import io.cucumber.datatable.DataTable; +import io.cucumber.java.ru.И; +import io.qameta.allure.Allure; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testng.Assert; +import ru.lanit.at.api.ApiRequest; +import ru.lanit.at.api.models.RequestModel; +import ru.lanit.at.utils.CompareUtil; +import ru.lanit.at.utils.ContextHolder; +import ru.lanit.at.utils.VariableUtil; + +import java.util.HashMap; +import java.util.Map; + +import static ru.lanit.at.utils.ContextHolder.replaceVarsIfPresent; +import static ru.lanit.at.utils.JsonUtil.getFieldFromJson; + +public class ApiSteps { + private static final Logger LOG = LoggerFactory.getLogger(ApiSteps.class); + private ApiRequest apiRequest; + + @И("создать запрос") + public void createRequest(RequestModel requestModel) { + apiRequest = new ApiRequest(requestModel); + } + + @И("добавить header") + public void addHeaders(DataTable dataTable) { + Map headers = new HashMap<>(); + dataTable.asLists().forEach(it -> headers.put(it.get(0), replaceVarsIfPresent(it.get(1)))); + apiRequest.setHeaders(headers); + } + + @И("добавить query параметры") + public void addQuery(DataTable dataTable) { + Map query = new HashMap<>(); + dataTable.asLists().forEach(it -> query.put(it.get(0), replaceVarsIfPresent(it.get(1)))); + apiRequest.setQuery(query); + } + + @И("отправить запрос") + public void send() { + apiRequest.sendRequest(); + } + + @И("статус код {int}") + public void expectStatusCode(int code) { + int actualStatusCode = apiRequest.getResponse().statusCode(); + Assert.assertEquals(actualStatusCode, code); + } + + @И("извлечь данные") + public void extractVariables(Map vars) { + String responseBody = apiRequest.getResponse().body().asPrettyString(); + vars.forEach((k, jsonPath) -> { + jsonPath = replaceVarsIfPresent(jsonPath); + String extractedValue = VariableUtil.extractBrackets(getFieldFromJson(responseBody, jsonPath)); + ContextHolder.put(k, extractedValue); + Allure.addAttachment(k, "application/json", extractedValue, ".txt"); + LOG.info("Извлечены данные: {}={}", k, extractedValue); + }); + } + + + @И("сравнить значения") + public void compareVars(DataTable table) { + table.asLists().forEach(it -> { + String expect = replaceVarsIfPresent(it.get(0)); + String actual = replaceVarsIfPresent(it.get(2)); + boolean compareResult = CompareUtil.compare(expect, actual, it.get(1)); + Assert.assertTrue(compareResult, String.format("Ожидаемое: '%s'\nФактическое: '%s'\nОператор сравнения: '%s'\n", expect, actual, it.get(1))); + Allure.addAttachment(expect, "application/json", expect + it.get(1) + actual, ".txt"); + LOG.info("Сравнение значений: {} {} {}", expect, it.get(1), actual); + }); + } +} diff --git a/src/main/java/ru/lanit/at/steps/api/type_params/DataTableTypeContainer.java b/src/main/java/ru/lanit/at/steps/api/type_params/DataTableTypeContainer.java new file mode 100644 index 0000000..f1bf16d --- /dev/null +++ b/src/main/java/ru/lanit/at/steps/api/type_params/DataTableTypeContainer.java @@ -0,0 +1,21 @@ +package ru.lanit.at.steps.api.type_params; + + +import io.cucumber.java.DataTableType; +import ru.lanit.at.api.models.RequestModel; + +import java.util.Map; + +public class DataTableTypeContainer { + + @DataTableType + public RequestModel requestModel(Map entry) { + return new RequestModel( + entry.get("method"), + entry.get("body"), + entry.get("path"), + entry.get("url") + ); + } + +} diff --git a/src/main/java/ru/lanit/at/steps/web/AbstractWebSteps.java b/src/main/java/ru/lanit/at/steps/web/AbstractWebSteps.java new file mode 100644 index 0000000..d7347b5 --- /dev/null +++ b/src/main/java/ru/lanit/at/steps/web/AbstractWebSteps.java @@ -0,0 +1,46 @@ +package ru.lanit.at.steps.web; + +import org.aeonbits.owner.ConfigFactory; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import ru.lanit.at.utils.ContextHolder; +import ru.lanit.at.utils.web.pagecontext.Environment; +import ru.lanit.at.utils.web.pagecontext.PageManager; +import ru.lanit.at.utils.web.pagecontext.WebPage; +import ru.lanit.at.utils.web.properties.Configurations; + +import java.util.Map; + + +public abstract class AbstractWebSteps { + protected final Configurations configurations; + protected Logger LOGGER = LogManager.getLogger(this.getClass()); + protected PageManager pageManager; + + + public AbstractWebSteps(PageManager pageManager) { + this.pageManager = pageManager; + configurations = ConfigFactory.create(Configurations.class, System.getProperties(), + System.getenv()); + } + + protected Map getStorage() { + return ContextHolder.asMap(); + } + + protected void saveValueInStorage(String key, Object value) { + getStorage().put(key, value); + } + + protected WebPage getPage(String name) { + WebPage page = Environment.getPage(name); + pageManager.setCurrentPage(page); + return page; + } + + protected T getPage(Class c) { + WebPage page = Environment.getPage(c); + pageManager.setCurrentPage(page); + return (T) page; + } +} diff --git a/src/main/java/ru/lanit/at/steps/web/DebugWebSteps.java b/src/main/java/ru/lanit/at/steps/web/DebugWebSteps.java new file mode 100644 index 0000000..c8b59e1 --- /dev/null +++ b/src/main/java/ru/lanit/at/steps/web/DebugWebSteps.java @@ -0,0 +1,17 @@ +package ru.lanit.at.steps.web; + + +import io.cucumber.java.ru.Если; +import ru.lanit.at.utils.web.pagecontext.PageManager; + +public class DebugWebSteps extends AbstractWebSteps { + + public DebugWebSteps(PageManager pageManager) { + super(pageManager); + } + + @Если("шаг № {string}") + public void stepNumber(String stepNum) { + LOGGER.info("Шаг номер " + stepNum); + } +} diff --git a/src/main/java/ru/lanit/at/steps/web/WebActionWebSteps.java b/src/main/java/ru/lanit/at/steps/web/WebActionWebSteps.java new file mode 100644 index 0000000..41777ec --- /dev/null +++ b/src/main/java/ru/lanit/at/steps/web/WebActionWebSteps.java @@ -0,0 +1,154 @@ +package ru.lanit.at.steps.web; + +import com.codeborne.selenide.Condition; +import com.codeborne.selenide.Selectors; +import com.codeborne.selenide.SelenideElement; +import io.cucumber.java.ru.Если; +import io.cucumber.java.ru.И; +import io.cucumber.java.ru.Когда; +import ru.lanit.at.actions.WebActions; +import ru.lanit.at.utils.Sleep; +import ru.lanit.at.utils.web.pagecontext.PageManager; + +import java.time.Duration; + +import static com.codeborne.selenide.Selenide.$; +import static ru.lanit.at.utils.VariableUtil.replaceVars; + + +public class WebActionWebSteps extends AbstractWebSteps { + + public WebActionWebSteps(PageManager pageManager) { + super(pageManager); + } + + + /** + * нажимает на элемент по тексту + * + * @param text текст элемента + */ + @Когда("кликнуть на элемент по тексту {string}") + public void clickElementWithText(String text) { + $(Selectors.byText(text)) + .shouldBe(Condition.visible) + .click(); + LOGGER.info("клик на элемент по тексту '{}'", text); + } + + @Если("кликнуть на элемент {string}") + public void clickOnElement(String elementName) { + SelenideElement element = pageManager + .getCurrentPage() + .getElement(elementName); + element + .shouldBe(Condition.visible, Duration.ofSeconds(10)) + .click(); + LOGGER.info("клик на элемент '{}'", elementName); + } + + @Если("установить чекбокс на элементе {string}") + public void selectCheckboxOnElement(String elementName) { + SelenideElement element = pageManager + .getCurrentPage() + .getElement(elementName); + element + .shouldBe(Condition.enabled, Duration.ofSeconds(60)); + WebActions.setCheckBox(element, true); + LOGGER.info("чекбокс установлен на элементе '{}'", elementName); + } + + @Если("убрать чекбокс на элементе {string}") + public void unselectCheckboxOnElement(String elementName) { + SelenideElement element = pageManager + .getCurrentPage() + .getElement(elementName); + element + .shouldBe(Condition.enabled, Duration.ofSeconds(60)); + WebActions.setCheckBox(element, false); + LOGGER.info("чекбокс снят на элементе '{}'", elementName); + } + + + /** + * скролл до элемента + * + * @param elementName наименование элемента + */ + @Когда("проскроллить страницу до элемента {string}") + public void scrollToElement(String elementName) { + SelenideElement element = pageManager.getCurrentPage().getElement(elementName); + element.shouldBe(Condition.visible) + .scrollIntoView("{block: 'center'}"); + LOGGER.info("скролл страницы до элемента '{}'", elementName); + } + + /** + * скролл до текста + * + * @param text текст + */ + @Когда("проскроллить страницу до текста {string}") + public void scrollToText(String text) { + SelenideElement element = $(Selectors.byText(text)); + element.shouldBe(Condition.visible) + .scrollIntoView("{block: 'center'}"); + LOGGER.info("скролл страницы до текста '{}'", text); + } + + @И("подождать {int} сек") + public void waitSeconds(int timeout) { + Sleep.pauseSec(timeout); + } + + /** + * Ввод значения в элемент + * + * @param field - наименование элемента + * @param value - значение + */ + @Когда("ввести в поле {string} значение {string}") + public void fillTheField(String field, String value) { + value = replaceVars(value, getStorage()); + SelenideElement fieldElement = pageManager + .getCurrentPage() + .getElement(field); + fieldElement + .shouldBe(Condition.visible, Duration.ofSeconds(60)) + .setValue(value); + LOGGER.info("в поле '{}' введено значение '{}'", field, value); + } + + /** + * Сохранить значения из элемент + * + * @param field - наименование элемента + * @param key - значение + */ + @Когда("сохранить значение из поля {string} под именем {string}") + public void saveTextField(String field, String key) { + SelenideElement fieldElement = pageManager + .getCurrentPage() + .getElement(field); + String elementValue = fieldElement + .shouldBe(Condition.visible, Duration.ofSeconds(60)) + .getValue(); + saveValueInStorage(key, elementValue); + LOGGER.info("значение '{}' сохранено под именем '{}'", elementValue, key); + } + + + /** + * Очистка поля + * + * @param elementName наименование элемента + */ + @Если("очистить поле {string}") + public void clearFiled(String elementName) { + pageManager + .getCurrentPage() + .getElement(elementName) + .shouldBe(Condition.visible) + .clear(); + } +} diff --git a/src/main/java/ru/lanit/at/steps/web/WebCheckWebSteps.java b/src/main/java/ru/lanit/at/steps/web/WebCheckWebSteps.java new file mode 100644 index 0000000..264bb04 --- /dev/null +++ b/src/main/java/ru/lanit/at/steps/web/WebCheckWebSteps.java @@ -0,0 +1,143 @@ +package ru.lanit.at.steps.web; + +import com.codeborne.selenide.SelenideElement; +import io.cucumber.java.ru.Когда; +import io.cucumber.java.ru.Тогда; +import ru.lanit.at.actions.WebChecks; +import ru.lanit.at.utils.web.pagecontext.PageManager; + +import java.util.List; + +public class WebCheckWebSteps extends AbstractWebSteps { + + public WebCheckWebSteps(PageManager pageManager) { + super(pageManager); + } + + + /** + * проверка присутствия текста на странице + * + * @param text текст + */ + @Когда("проверить что элемент {string} содержит текст:") + public void textAppearOnThePage(String elementName, List text) { + SelenideElement element = pageManager + .getCurrentPage() + .getElement(elementName); + WebChecks.elementContainsText(element, text); + LOGGER.info("элемент '{}' содержит текст '{}'", elementName, text); + } + + + /** + * проверка присутствия текста на странице + * + * @param text текст + */ + @Когда("на странице присутствует текст {string}") + public void textAppearOnThePage(String text) { + WebChecks.textVisibleOnPage(text, null); + LOGGER.info("на странице '{}' имеется текст '{}'", pageManager.getCurrentPage().name(), text); + } + + /** + * проверка отсутствия текста на странице + * + * @param text текст + */ + @Когда("на странице отсутствует текст {string}") + public void textVisibleOnPage(String text) { + WebChecks.textAbsentOnPage(text, null); + LOGGER.info("на странице '{}' отсутствует текст '{}'", pageManager.getCurrentPage().name(), text); + } + + /** + * ожидание появления текста на странице в течении некоторого времени + * + * @param text текст + * @param timeoutSeconds количество секунд + */ + @Когда("подождать появления текста {string} в течение {int} секунд") + public void waitUntilTextAppearOnPage(String text, int timeoutSeconds) { + WebChecks.textVisibleOnPage(text, timeoutSeconds); + LOGGER.info("на странице '{}' имеется текст '{}'", pageManager.getCurrentPage().name(), text); + } + + /** + * ожидание исчезновения текста на странице в течении некоторого времени + * + * @param text текст + * @param timeoutSeconds количество секунд + */ + @Когда("подождать исчезновения текста {string} в течение {int} секунд") + public void waitUntilTextAbsentOnPage(String text, int timeoutSeconds) { + WebChecks.textAbsentOnPage(text, timeoutSeconds); + LOGGER.info("на странице '{}' отсутствует текст '{}'", pageManager.getCurrentPage().name(), text); + } + + /** + * ожидание элемента на странице в течении некоторого времени + * + * @param elementName наименование элемента + * @param timeoutSeconds количество секунд + */ + @Когда("подождать появления элемента {string} в течение {int} секунд") + public void waitUntilElementIsVisibleOnPage(String elementName, int timeoutSeconds) { + SelenideElement element = pageManager + .getCurrentPage() + .getElement(elementName); + WebChecks.elementVisibleOnPage(element, timeoutSeconds); + LOGGER.info("на странице '{}' имеется элемент '{}'", pageManager.getCurrentPage().name(), elementName); + } + + /** + * проверка что на странице отображен элемент + * + * @param elementName наименование элемента + */ + @Когда("на странице имеется элемент {string}") + public void elementAppearOnThePage(String elementName) { + SelenideElement element = pageManager + .getCurrentPage() + .getElement(elementName); + WebChecks.elementVisibleOnPage(element, null); + LOGGER.info("на странице '{}' имеется элемент '{}'", pageManager.getCurrentPage().name(), elementName); + } + + /** + * проверка что на странице отсуствует элемент + * + * @param elementName наименование элемента + */ + @Когда("на странице отсутствует элемент {string}") + public void elementAbsentOnPage(String elementName) { + SelenideElement element = pageManager + .getCurrentPage() + .getElement(elementName); + WebChecks.elementAbsentOnPage(element, null); + LOGGER.info("на странице '{}' отсутствует элемент '{}'", pageManager.getCurrentPage().name(), elementName); + } + + /** + * проверка текущего url + *
можно начать написание url с переменной %{apiUrl}% или %{webUrl}% + * + * @param url часть или полный url (также может содержать переменные) + */ + @Тогда("проверить что текущий url соответствует {string}") + public void currentUrlEqualsExpected(String url) { + WebChecks.urlEquals(url); + } + + /** + * проверка текущего url + *
можно начать написание url с переменной %{apiUrl}% или %{webUrl}% + * + * @param url часть url (также может содержать переменные) + */ + @Тогда("проверить что текущий url содержит текст {string}") + public void currentUrlContainsExpected(String url) { + WebChecks.urlContains(url); + } +} diff --git a/src/main/java/ru/lanit/at/steps/web/WindowWebSteps.java b/src/main/java/ru/lanit/at/steps/web/WindowWebSteps.java new file mode 100644 index 0000000..9371e8c --- /dev/null +++ b/src/main/java/ru/lanit/at/steps/web/WindowWebSteps.java @@ -0,0 +1,103 @@ +package ru.lanit.at.steps.web; + + +import com.codeborne.selenide.Selenide; +import com.codeborne.selenide.WebDriverRunner; +import io.cucumber.java.ru.Если; +import io.cucumber.java.ru.И; +import io.cucumber.java.ru.Когда; +import ru.lanit.at.actions.WebActions; +import ru.lanit.at.utils.selenide.DriverManager; +import ru.lanit.at.utils.web.pagecontext.PageManager; +import ru.lanit.at.utils.web.pagecontext.WebPage; + +public class WindowWebSteps extends AbstractWebSteps { + + public WindowWebSteps(PageManager pageManager) { + super(pageManager); + } + + + /** + * открывает браузер + */ + @Если("открыть браузер") + public void openDriver() { + DriverManager.startDriver(); + DriverManager.startApp(); + } + + + /** + * открывает страницу по ссылке + * + * @param url url + */ + @Если("открыть url {string}") + public void open(String url) { + DriverManager.startDriver(); + Selenide.open(url); + } + + /** + * открывает новую вкладку в браузере с url и переключается на нее + * + * @param url url + */ + @И("открыть новую вкладку с url {string}") + public void openNewTab(String url) { + WebActions.openUrlOnNewTab(url); + } + + /** + * если вкладок 2, то переключится на следующую вкладку. + *
по факту переключается на последнюю вкладку + */ + @И("переключиться на следующую вкладку") + public void switchNextTab() { + WebActions.switchToNextTab(null); + } + + /** + * переключается на вкладку по порядковому номеру + * + * @param number порядковый номер вкладки в браузере + */ + @И("пеерключиться на вкладку по порядковому номеру {int}") + public void switchNextTabByNumber(int number) { + WebActions.switchToNextTab(number); + } + + /** + * закрывает текущую вкладку и переходит на предыдущую (последняя вкладка в наборе) + */ + @И("закрыть текущую вкладку и перейти на предыдущую") + public void closeTabAndSwitchTab() { + WebActions.closeCurrentTabAndSwitchToPrevious(); + } + + /** + * закрывает страницу + */ + @Если("закрыть страницу") + public void closeDriver() { + WebDriverRunner.getWebDriver().close(); + } + + /** + * действие обозначает что мы находимся на определенной странице + * для работы с описанными элементами в пейдже + * + * @param pageName наименование страницы + */ + @Если("пользователь на странице {string}") + @Когда("инициализация страницы {string}") + @И("переход на страницу {string}") + + public void setPage(String pageName) { + WebPage page = getPage(pageName); + pageManager.setCurrentPage(page); + } + + +} diff --git a/src/main/java/ru/lanit/at/steps/web/pages/GooglePageWebSteps.java b/src/main/java/ru/lanit/at/steps/web/pages/GooglePageWebSteps.java new file mode 100644 index 0000000..0eb0834 --- /dev/null +++ b/src/main/java/ru/lanit/at/steps/web/pages/GooglePageWebSteps.java @@ -0,0 +1,16 @@ +package ru.lanit.at.steps.web.pages; + +import ru.lanit.at.steps.web.AbstractWebSteps; +import ru.lanit.at.utils.web.pagecontext.PageManager; + + +public class GooglePageWebSteps extends AbstractWebSteps { + + public GooglePageWebSteps(PageManager pageManager) { + super(pageManager); + } + + /// реализация шагов специфичных для страницы GooglePage + + +} diff --git a/src/main/java/ru/lanit/at/utils/CompareUtil.java b/src/main/java/ru/lanit/at/utils/CompareUtil.java new file mode 100644 index 0000000..166306f --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/CompareUtil.java @@ -0,0 +1,62 @@ +package ru.lanit.at.utils; + +import org.apache.commons.lang3.math.NumberUtils; + +import java.math.BigDecimal; + +/** + * Created by Sorokin Boris on 21.01.2022. + */ +public class CompareUtil { + public static boolean compare(String firstValue, String secondValue, String operator) { + if (isNumeric(firstValue) && isNumeric(secondValue)) { + return compareNumbers(firstValue, secondValue, operator); + } else { + return compareStrings(firstValue, secondValue, operator); + } + } + + private static boolean compareStrings(String firstValue, String secondValue, String operator) { + switch (operator.toLowerCase()) { + case "равно": + case "==": + return firstValue.equals(secondValue); + case "не равно": + case "!=": + return !firstValue.equals(secondValue); + case "содержит": + return firstValue.contains(secondValue); + case "не содержит": + return !firstValue.contains(secondValue); + default: + throw new IllegalArgumentException(String + .format("Параметр '%s' является неверным для метода '%s'.", operator, "compareStrings")); + } + } + + private static boolean compareNumbers(String firstValue, String secondValue, String operator) { + BigDecimal number1 = new BigDecimal(firstValue); + BigDecimal number2 = new BigDecimal(secondValue); + switch (operator.toLowerCase()) { + case "равно": + case "==": + return number1.equals(number2); + case "не равно": + case "!=": + return !number1.equals(number2); + case "больше": + case ">": + return number1.compareTo(number2) > 0; + case "меньше": + case "<": + return number1.compareTo(number2) < 0; + default: + throw new IllegalArgumentException(String + .format("Параметр '%s' является неверным для метода '%s'.", operator, "compareNumbers")); + } + } + + private static boolean isNumeric(String strNum) { + return NumberUtils.isParsable(strNum); + } +} diff --git a/src/main/java/ru/lanit/at/utils/ContextHolder.java b/src/main/java/ru/lanit/at/utils/ContextHolder.java new file mode 100644 index 0000000..a3a8439 --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/ContextHolder.java @@ -0,0 +1,48 @@ +package ru.lanit.at.utils; + + +import java.util.HashMap; +import java.util.Map; + +/** + * Класс для хранения переменных теста + * Синтаксис %{var_name}% + */ +public class ContextHolder { + + private static final ThreadLocal> THREAD = new ThreadLocal<>(); + + private static Map getThread() { + Map vault = THREAD.get(); + if (vault == null) { + vault = new HashMap<>(); + THREAD.set(vault); + } + return vault; + } + + + public static Map asMap() { + return getThread(); + } + + public static void put(String key, Object value) { + getThread().put(key, value); + } + + public static String replaceVarsIfPresent(String text) { + if (text == null) { + return ""; + } else { + return VariableUtil.replaceVars(text, asMap()); + } + } + + public static T getValue(String key) { + return (T) getThread().get(key); + } + + public static T remove(String key) { + return (T) getThread().remove(key); + } +} \ No newline at end of file diff --git a/src/main/java/ru/lanit/at/utils/DataGenerator.java b/src/main/java/ru/lanit/at/utils/DataGenerator.java new file mode 100644 index 0000000..bed811a --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/DataGenerator.java @@ -0,0 +1,56 @@ +package ru.lanit.at.utils; + +import java.util.concurrent.ThreadLocalRandom; + +import static org.apache.commons.lang3.RandomStringUtils.randomAlphabetic; +import static org.apache.commons.lang3.RandomStringUtils.randomNumeric; + +public class DataGenerator { + + /** + * Генерирует набор русских, английских букв и цифр по маске + *

+ *
R - русская буква + *
D - цифра + *
E - английская буква + * + * @return - рандомная строка + */ + public static String generateValueByMask(String mask) { + StringBuilder result = new StringBuilder(); + char[] chars = mask.toCharArray(); + for (char aChar : chars) { + switch (String.valueOf(aChar)) { + case "R": + result.append(getRussianLetter()); + break; + case "D": + result.append(randomNumeric(1)); + break; + case "E": + result.append(randomAlphabetic(1).toLowerCase()); + break; + default: + result.append(aChar); + break; + } + } + result.trimToSize(); + return result.toString(); + } + + /** + * рандомная русская буква + * + * @return рандомная русская буква + */ + private static String getRussianLetter() { + int leftLimit = 1040; + int rightLimit = leftLimit + 33; + String res = ""; + int a = ThreadLocalRandom.current().nextInt(leftLimit, rightLimit + 1); + char symbol = (char) a; + res += symbol; + return res; + } +} diff --git a/src/main/java/ru/lanit/at/utils/ErrorMessage.java b/src/main/java/ru/lanit/at/utils/ErrorMessage.java new file mode 100644 index 0000000..e4445b7 --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/ErrorMessage.java @@ -0,0 +1,10 @@ +package ru.lanit.at.utils; + +public class ErrorMessage { + + public static final String BROWSER_NOT_SUPPORTED = "Браузер [%s] не поддерживается"; + public static final String BROWSER_TAB_NUMBER_MORE_THAN_TABS = "Номер [%d] переданной вкладки больше, чем количество вкладок"; + public static final String URL_NOT_EQUAL_ACTUAL = "Ожидаемый url [%s] не равен текущему [%s]"; + public static final String URL_NOT_CONTAINS_TEXT = "Текущий url [%s] не содержит в себе [%s]"; + +} diff --git a/src/main/java/ru/lanit/at/utils/FileUtil.java b/src/main/java/ru/lanit/at/utils/FileUtil.java new file mode 100644 index 0000000..582803d --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/FileUtil.java @@ -0,0 +1,95 @@ +package ru.lanit.at.utils; + +import java.io.File; +import java.io.IOException; +import java.nio.file.FileSystemNotFoundException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.stream.Collectors; + +public class FileUtil { + + /** + * поиск файла по заданному пути и имени файла + *
если нет ни одного, то ошибка. если более одного, то тоже ошибка + * + * @param path путь + * @param fileName наименование файла + * @return возвращает один файл + */ + public static File searchFileInDirectory(String path, String fileName) { + List paths; + try { + paths = Files.find(Paths.get(path), + Integer.MAX_VALUE, + (path1, basicFileAttributes) -> path1.toFile().getName().equals(fileName)) + .collect(Collectors.toList()); + } catch (IOException e) { + e.printStackTrace(); + throw new FileSystemNotFoundException("Файл не найден"); + } + + if (paths.size() > 1) { + throw new AssertionError("В каталоге более 1 файла с именем " + fileName); + } else if (paths.size() == 0) { + throw new FileSystemNotFoundException("Файл не найден"); + } else { + return paths.get(0).toFile(); + } + } + + /** + * метод для составления полного пути до директории или файла + * + * @param packages массив наименований папок или файла (в конце) + * @return полный путь до директории или файла + */ + public static StringBuilder getParentPath(String... packages) { + StringBuilder pathBuilder = new StringBuilder(); + String separator = System.getProperty("file.separator"); + if (!System.getProperty("project.dir", "").isEmpty()) { + pathBuilder.append(System.getProperty("project.dir")); + } else { + pathBuilder.append(System.getProperty("user.dir")); + } + for (String packageName : packages) { + if (pathBuilder.toString().endsWith(separator)) { + pathBuilder.append(packageName); + } else { + pathBuilder.append(separator).append(packageName); + } + } + + return pathBuilder; + } + + /** + * Считывает и возвращает содержимый текст файла + * + * @param fileName - название файла + * @param packages - директории, в которых лежит файл + * @return - содержимый текст файла + */ + private static String readBodyFromFile(String fileName, String... packages) { + File file = searchFileInDirectory(getParentPath(packages).toString(), fileName); + try { + return new String(Files.readAllBytes(Paths.get(file.getAbsolutePath()))); + } catch (IOException e) { + e.printStackTrace(); + } + return null; + } + + /** + * Считывает json-файл из пакеты по пути: "resources", "json" + * Путь часто используется, поэтому вынесен в отдельным метод + * + * @param fileName - название файла + * @return - содержание файла + */ + public static String readBodyFromJsonDir(String fileName) { + return readBodyFromFile(fileName, "src", "test", "resources", "json"); + } +} diff --git a/src/main/java/ru/lanit/at/utils/JsonUtil.java b/src/main/java/ru/lanit/at/utils/JsonUtil.java new file mode 100644 index 0000000..0481515 --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/JsonUtil.java @@ -0,0 +1,58 @@ +package ru.lanit.at.utils; + +import com.fasterxml.jackson.databind.JsonNode; +import com.jayway.jsonpath.Configuration; +import com.jayway.jsonpath.InvalidJsonException; +import com.jayway.jsonpath.JsonPath; +import com.jayway.jsonpath.spi.json.JacksonJsonProvider; +import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider; +import io.qameta.allure.Allure; + +import java.nio.charset.StandardCharsets; + + +public class JsonUtil { + + /** + * парсит к utf-8 + * + * @param text текст + * @return текст в utf-8 (насколько это возможно) + */ + public static String jsonToUtf(String text) { + return new String(text.getBytes(), StandardCharsets.UTF_8); + } + + /** + * извлекает данные по json path из json + * + * @param body json + * @param jsonPath json path + * @return значение из json + */ + public static String getFieldFromJson(String body, String jsonPath) { + Configuration jacksonConfig = Configuration.builder() + .mappingProvider(new JacksonMappingProvider()) + .jsonProvider(new JacksonJsonProvider()) + .build(); + + String val; + JsonNode node; + try { + node = JsonPath.using(jacksonConfig).parse(body).read(jsonPath, JsonNode.class); + } catch (InvalidJsonException e) { + Allure.addAttachment("INVALID JSON", "application/json", body, ".txt"); + throw new InvalidJsonException("Невалидный json."); + } + + if (node == null || node.isNull()) { + val = "null"; + } else { + val = node.toString(); + } + + String matchValue = RegexUtil.getMatchValueByGroupNumber(val, "^\"(.*)\"$", 1); + val = matchValue == null ? val : matchValue; + return val; + } +} diff --git a/src/main/java/ru/lanit/at/utils/RegexUtil.java b/src/main/java/ru/lanit/at/utils/RegexUtil.java new file mode 100644 index 0000000..299d6f7 --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/RegexUtil.java @@ -0,0 +1,44 @@ +package ru.lanit.at.utils; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class RegexUtil { + + /** + * получение значения из текста по регулярному выражению + * + * @param sentence текст + * @param regex регулярное выражение + * @param groupNumber номер группы + * @return значения из текста по регулярному выражению; если не нашел, то null + */ + public static String getMatchValueByGroupNumber(String sentence, String regex, int groupNumber) { + if (sentence != null && regex != null) { + sentence = sentence.trim(); + Pattern pattern = Pattern.compile(regex); + Matcher matcher = pattern.matcher(sentence); + if (matcher.find()) { + return matcher.group(groupNumber); + } + } + return null; + } + + /** + * есть совпадение в тексте по регулярному выражению + * + * @param sentence текст + * @param regex регулярное выражение + * @return true - есть совпадение; false - нет совпадения + */ + public static Boolean getMatch(String sentence, String regex) { + if (sentence != null && regex != null) { + Pattern pattern = Pattern.compile(regex); + Matcher matcher = pattern.matcher(sentence); + return matcher.find(); + } + return null; + } + +} \ No newline at end of file diff --git a/src/main/java/ru/lanit/at/utils/Sleep.java b/src/main/java/ru/lanit/at/utils/Sleep.java new file mode 100644 index 0000000..a609e7c --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/Sleep.java @@ -0,0 +1,26 @@ +package ru.lanit.at.utils; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.TimeUnit; + +public class Sleep { + + private final static Logger LOGGER = LoggerFactory.getLogger(Sleep.class); + + public static void pauseSec(int sec) { + LOGGER.info("Ожидание {} секунд", sec); + sleep(sec * 1000); + } + + public static void sleep(long ms) { + try { + LOGGER.info("Ожидание {} миллисекунд", ms); + TimeUnit.MILLISECONDS.sleep(ms); + } catch (InterruptedException e) { + LOGGER.warn("Interrupted!", e); + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/main/java/ru/lanit/at/utils/Stand.java b/src/main/java/ru/lanit/at/utils/Stand.java new file mode 100644 index 0000000..1cd6615 --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/Stand.java @@ -0,0 +1,27 @@ +package ru.lanit.at.utils; + +import java.util.Arrays; + +/** + * Адреса стендов + */ +public enum Stand { + GOOGLE("www.google.ru"), + YANDEX("ya.ru"); + + private String url; + + Stand(String url) { + this.url = url; + } + + public static Stand getByName(String value) { + return Arrays.stream(values()) + .filter(stand -> stand.name().equalsIgnoreCase(value)).findFirst().orElseThrow(() -> new RuntimeException("Не определен подходящий стенд с именем " + value)); + } + + public String getUrlPath() { + return url; + } + +} diff --git a/src/main/java/ru/lanit/at/utils/VariableUtil.java b/src/main/java/ru/lanit/at/utils/VariableUtil.java new file mode 100644 index 0000000..6590672 --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/VariableUtil.java @@ -0,0 +1,76 @@ +package ru.lanit.at.utils; + + +import org.apache.commons.lang3.StringUtils; + +import java.math.BigDecimal; +import java.util.Map; + +public class VariableUtil { + + /** + * замена контекстных переменных на значения + * + * @param preBody текст + * @param vars контекстные переменные + * @return значение + */ + public static String replaceVars(String preBody, Map vars) { + StringBuilder replacedText = new StringBuilder(preBody); + + final String patternStartVar = "${"; + final String patternEndVar = "}"; + + while (true) { + int fi = replacedText.indexOf(patternStartVar); + if (fi == -1) { + break; + } + int li = replacedText.indexOf(patternEndVar, fi); + String var = replacedText.substring(fi + patternStartVar.length(), li); + if (vars.containsKey(var)) { + replacedText.replace(fi, li + 1, vars.get(var).toString()); + } else { + break; + } + } + return replacedText.toString(); + } + + /** + * убирает лишние символы (обычно для json) + * + * @param in текст + * @return текст + */ + public static String extractBrackets(Object in) { + if (in == null) { + return "null"; + } + String op = in.toString(); + String replace; + String replace2; + if (op.startsWith("[") && op.endsWith("]") && !op.contains(",")) { + replace = StringUtils.replace(StringUtils.replace(op, "[", ""), "]", ""); + if (StringUtils.startsWith(replace, "\"") && StringUtils.endsWith(replace, "\"")) { + replace2 = StringUtils.replace(replace, "\"", ""); + try { + if (replace2.matches("^[0-9]+$")) { + new BigDecimal(replace2); + } + return replace2; + } catch (NumberFormatException n) { + return replace; + } + } else { + return replace; + } + } + if (op.startsWith("[[") && op.endsWith("]]") || op.startsWith("[") && op.endsWith("]")) { + op = op.replaceFirst("\\[", ""); + int lastIndex = op.lastIndexOf("]"); + return op.substring(0, lastIndex); + } + return op; + } +} \ No newline at end of file diff --git a/src/main/java/ru/lanit/at/utils/VideoSaveHelper.java b/src/main/java/ru/lanit/at/utils/VideoSaveHelper.java new file mode 100644 index 0000000..06042d8 --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/VideoSaveHelper.java @@ -0,0 +1,78 @@ +package ru.lanit.at.utils; + + +import com.codeborne.selenide.Selenide; +import io.restassured.RestAssured; +import io.restassured.response.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import ru.lanit.at.utils.allure.AllureHelper; + +import java.net.URI; +import java.net.URL; + +import static io.restassured.RestAssured.given; + +public class VideoSaveHelper { + private final static Logger LOGGER = LoggerFactory.getLogger(VideoSaveHelper.class); + + private final String sessionId; + private final String hubUrl; + + public VideoSaveHelper(String sessionId, String hubUrl) { + this.sessionId = sessionId; + this.hubUrl = hubUrl; + } + + /** + * Метод для прикрепления видео к Аллюр отчету + */ + public void attachVideoFileRest() { + waitVideoFileDone(); + Response response = RestAssured.given().when() + .get(buildVideoURL(sessionId)); + AllureHelper.attachSelenoidVideo("Video", response + .then().extract().asInputStream()); + } + + /** + * Метод для удаления видео из селенойда. + */ + public void deleteSelenoidVideoRest() { + try { + given().delete(buildVideoURL(sessionId)).then().statusCode(200); + } catch (Exception e) { + LOGGER.warn("Произошла ошибка при удалении видео: " + e.getMessage()); + } + } + + + /** + * Метод для построения урл с видео файлом + */ + private URL buildVideoURL(String sessionId) { + try { + URI uri = new URI("http", hubUrl, "/video/" + sessionId + ".mp4", null, null); + URL url = uri.toURL(); + LOGGER.info("URL для скачивания файла с видео = " + url); + return url; + } catch (Exception e) { + throw new RuntimeException("Не удалось создать url для скачивания видео", e); + } + } + + /** + * Ожидание формирования видео в селенойде + */ + private void waitVideoFileDone() { + for (int i = 0; i < 60; i++) { + if (RestAssured.given().when() + .get(buildVideoURL(sessionId)).statusCode() == 200) { + return; + } + LOGGER.info("Ожидание подготовки видео файла для скачивания"); + Selenide.sleep(5000); + } + throw new RuntimeException("Ошибка ожидания подготовки видео файла для скачивания"); + } +} \ No newline at end of file diff --git a/src/main/java/ru/lanit/at/utils/allure/AllureHelper.java b/src/main/java/ru/lanit/at/utils/allure/AllureHelper.java new file mode 100644 index 0000000..d8f4701 --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/allure/AllureHelper.java @@ -0,0 +1,92 @@ +package ru.lanit.at.utils.allure; + + +import io.qameta.allure.Allure; +import io.qameta.allure.AllureLifecycle; +import io.qameta.allure.model.Status; +import io.qameta.allure.model.StepResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import ru.lanit.at.utils.Sleep; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import static io.qameta.allure.Allure.getLifecycle; + +public class AllureHelper { + private final static Logger LOGGER = LoggerFactory.getLogger(Sleep.class); + private static ThreadLocal threadLocal = new ThreadLocal<>(); + + /* Установка AllureLifecycle для потока выполнения, используется при запуске фреймворка, как веб сервиса **/ + public static void setLifecycle(AllureLifecycle allureLifecycle) { + threadLocal.set(allureLifecycle); + } + + public static void attachScreenShot(String name, byte[] bytes) { + get().addAttachment(name, "image/png", "png", bytes); + } + + public static void attachTxt(String name, String text) { + get().addAttachment(name, "text/plain", "txt", text.getBytes(StandardCharsets.UTF_8)); + } + + public static void attachPageSource(byte[] bytes) { + get().addAttachment("Page source", "text/html", "html", bytes); + } + + public static void setStepStatusBroken(String description) { + get().updateStep(stepResult -> stepResult.setDescription(description)); + } + + public static void addInfoAllureStep(String stepName) { + String uuid = UUID.randomUUID().toString(); + createAllureStep(uuid, stepName, Status.PASSED); + LOGGER.info("выполняется шаг : \"" + stepName + "\""); + stopAllureStep(uuid); + } + + public static void addInfoAllureStep(String uuid, String stepName) { + createAllureStep(uuid, stepName, Status.PASSED); + LOGGER.info("выполняется шаг : \"" + stepName + "\""); + stopAllureStep(uuid); + } + + public static void addBrokenAllureStep(String stepName, String textMessage) { + String uuid = UUID.randomUUID().toString(); + stepName = stepName + " [" + textMessage + "]"; + createAllureStep(uuid, stepName, Status.BROKEN); + stopAllureStep(uuid); + } + + + public static void createAllureStep(String parentUuid, String uuid, String stepName, Status status) { + LOGGER.info("Создание аллюр шага с именем :" + stepName); + StepResult stepResult = new StepResult(); + stepResult.setName(stepName) + .setStatus(status); + get().startStep(parentUuid, uuid, stepResult); + } + + public static void createAllureStep(String uuid, String stepName, Status status) { + LOGGER.info("Создание аллюр шага с именем :" + stepName); + StepResult stepResult = new StepResult(); + stepResult.setName(stepName) + .setStatus(status); + get().startStep(uuid, stepResult); + } + + public static void stopAllureStep(String uuid) { + get().stopStep(uuid); + } + + public static void attachSelenoidVideo(String name, InputStream is) { + Allure.getLifecycle().addAttachment(name, "video/mp4", "mp4", is); + } + + + private static AllureLifecycle get() { + return getLifecycle(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/lanit/at/utils/allure/AllureLogger.java b/src/main/java/ru/lanit/at/utils/allure/AllureLogger.java new file mode 100644 index 0000000..66e0310 --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/allure/AllureLogger.java @@ -0,0 +1,125 @@ +package ru.lanit.at.utils.allure; + +import com.codeborne.selenide.WebDriverRunner; +import io.qameta.allure.Allure; +import io.qameta.allure.listener.StepLifecycleListener; +import io.qameta.allure.listener.TestLifecycleListener; +import io.qameta.allure.model.Status; +import io.qameta.allure.model.StepResult; +import io.qameta.allure.model.TestResult; +import org.aeonbits.owner.ConfigFactory; +import org.openqa.selenium.OutputType; +import org.openqa.selenium.TakesScreenshot; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import ru.lanit.at.utils.web.properties.Configurations; + +import java.io.ByteArrayInputStream; +import java.util.ArrayList; +import java.util.List; + +import static ru.lanit.at.assertion.AssertErrorType.CRITICAL_ASSERT; +import static ru.lanit.at.assertion.AssertErrorType.SOFT_ASSERT; + +//import ru.lanit.at.utils.web.properties.Configurations; + +public class AllureLogger implements StepLifecycleListener, TestLifecycleListener { + private final static Logger LOGGER = LoggerFactory.getLogger(AllureLogger.class); + + private final Configurations conf = ConfigFactory.create(Configurations.class, System.getProperties(), + System.getenv()); + + @Override + public void beforeStepStop(StepResult result) { + boolean screenAfterStep = conf.screenAfterStep(); + if (screenAfterStep && !result.getStatus().equals(Status.SKIPPED)) { + Allure.addAttachment(result.getName(), + new ByteArrayInputStream(((TakesScreenshot) + WebDriverRunner.getWebDriver()) + .getScreenshotAs(OutputType.BYTES))); + } + } + + @Override + public void afterStepStop(StepResult result) { + if (!result.getStatus().equals(Status.SKIPPED)) { + if (isHasInnerBrokenStep(result)) { + LOGGER.info("Устанавливаем для шага: '" + result.getName() + "', статус=BROKEN"); + result.setStatus(Status.BROKEN); + } + } + + + if (result.getDescription() != null && (result.getDescription().contains(SOFT_ASSERT.getName()) || result.getDescription().contains(CRITICAL_ASSERT.getName()))) { + LOGGER.info("Устанавливаем для шага: '" + result.getName() + "', статус=BROKEN"); + result.setStatus(Status.BROKEN); + } + } + + private boolean isHasInnerBrokenStep(StepResult result) { + for (StepResult r : result.getSteps()) { + if (r.getStatus().equals(Status.BROKEN)) { + return true; + } + } + return false; + } + + /** + * Ищет шаги-заглушки ФРАГМЕНТ "название фрагмента" и прячет шаги фрагмента под спойлер + * @param testResult - результаты тестов + */ + @Override + public void beforeTestWrite(TestResult testResult) { + List originSteps = testResult.getSteps(); + List newSteps = wrapFragment(originSteps); + testResult.setSteps(newSteps); + } + + /** + * Метод прячет все шаги фрагмента под спойлер + * @param originSteps - оригинальный набор шагов для отчета + * @return - переопределенные шаги отчета + */ + private List wrapFragment(List originSteps) { + List newSteps = new ArrayList<>(); + for (int i=0; i < originSteps.size(); i++) { + StepResult step = originSteps.get(i); + if (step.getName().contains("ФРАГМЕНТ") && i+1 < originSteps.size()) { + i++; + long timeStop = step.getStop(); + List subSteps = new ArrayList<>(); + List statusList = new ArrayList<>(); + //идем дальше по тесту и собираем все шаги фрагмента в отдельный список + do { + StepResult subStep = originSteps.get(i); + if (subStep.getName().contains(step.getName())) { + timeStop = subStep.getStop(); + break; + } else { + subSteps.add(subStep); + statusList.add(subStep.getStatus()); + i++; + } + } while (i < originSteps.size()); + //если есть фрагменты внутри фрагмента + subSteps = wrapFragment(subSteps); + //таки все шаги фрагмента уйдут под спойлер (как саб-шаги) + step.setSteps(subSteps); + //ставим изменяем время конца шага, чтобы была сумма всех саб-шагов + step.setStop(timeStop); + //меняем статус шагу-фрагменту, если статус у одного из шагов не passed или не skipped + if (statusList.contains(Status.FAILED)) { + step.setStatus(Status.FAILED); + } else if (statusList.contains(Status.BROKEN)) { + step.setStatus(Status.BROKEN); + } + newSteps.add(step); + } else { + newSteps.add(step); + } + } + return newSteps; + } + +} \ No newline at end of file diff --git a/src/main/java/ru/lanit/at/utils/reflections/ReflectionUtil.java b/src/main/java/ru/lanit/at/utils/reflections/ReflectionUtil.java new file mode 100644 index 0000000..961bca0 --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/reflections/ReflectionUtil.java @@ -0,0 +1,31 @@ +package ru.lanit.at.utils.reflections; + +import org.reflections.Reflections; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.util.Set; + +public final class ReflectionUtil { + + /** + * Получение списка классов по аннотации + */ + public static Set> getPagesAnnotatedWith(String packageName, Class annotation) { + return new Reflections(packageName).getTypesAnnotatedWith(annotation); + } + + /** + * Получение поля класса с помощью механизма рефлексии + */ + public static Object extractFieldValue(Field field, Object owner) { + field.setAccessible(true); + try { + return field.get(owner); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } finally { + field.setAccessible(false); + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/lanit/at/utils/selenide/DriverManager.java b/src/main/java/ru/lanit/at/utils/selenide/DriverManager.java new file mode 100644 index 0000000..d26a9cf --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/selenide/DriverManager.java @@ -0,0 +1,94 @@ +package ru.lanit.at.utils.selenide; + +import com.codeborne.selenide.Configuration; +import com.codeborne.selenide.FileDownloadMode; +import com.codeborne.selenide.Selenide; +import com.codeborne.selenide.WebDriverRunner; +import com.codeborne.selenide.logevents.SelenideLogger; +import io.github.bonigarcia.wdm.WebDriverManager; +import io.qameta.allure.selenide.AllureSelenide; +import org.aeonbits.owner.ConfigFactory; +import ru.lanit.at.utils.ErrorMessage; +import ru.lanit.at.utils.Stand; +import ru.lanit.at.utils.web.pagecontext.Environment; +import ru.lanit.at.utils.web.properties.Configurations; +import ru.lanit.at.utils.web.properties.WebConfigurations; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + + +public class DriverManager { + + public static void startDriver() { + if (!WebDriverRunner.hasWebDriverStarted()) { + createDriver(); + } + } + + + private static void createDriver() { + Configurations cf = ConfigFactory.create(Configurations.class + , System.getProperties(), + System.getenv()); + + + WebConfigurations cfg = ConfigFactory.create(WebConfigurations.class + , System.getProperties(), + System.getenv()); + + SelenideLogger.addListener("AllureSelenide", new AllureSelenide() + .screenshots(true) + .savePageSource(true) + ); + + if (!cf.getRemoteURL().isEmpty()) { + Configuration.remote = "http://" + cf.getRemoteURL() + "/wd/hub"; + Map options = new HashMap<>(); + options.put("enableVNC", cf.getEnableVNC()); + options.put("enableVideo", cf.getEnableVideo()); + options.put("enableLog", cf.getEnableLog()); + Configuration.browserCapabilities.setCapability("selenoid:options", options); + Configuration.browserCapabilities.setCapability("sessionTimeout", "30m"); + Configuration.fileDownload = FileDownloadMode.FOLDER; + } else { + switch (cfg.webDriverBrowserName()) { + case "chrome": + WebDriverManager.chromedriver().setup(); + break; + case "firefox": + WebDriverManager.firefoxdriver().setup(); + break; + case "edge": + WebDriverManager.edgedriver().setup(); + break; + default: { + throw new IllegalArgumentException(String.format(ErrorMessage.BROWSER_NOT_SUPPORTED, cfg.webDriverBrowserName())); + } + } + } + + Configuration.browser = cfg.webDriverBrowserName(); + Configuration.browserSize = cfg.webDriverBrowserSize(); + Configuration.browserVersion = cfg.webDriverVersion(); + Configuration.savePageSource = true; + Configuration.screenshots = true; + Configuration.webdriverLogsEnabled = false; + Configuration.timeout = TimeUnit.SECONDS.toMillis(cfg.webDriverTimeoutSeconds()); + Configuration.pageLoadTimeout = TimeUnit.SECONDS.toMillis(cfg.pageLoadTimeoutSeconds()); + Configuration.pollingInterval = cfg.pollingTimeoutMs(); + Configuration.reportsFolder = System.getProperty("selenide.report.folder"); + Configuration.downloadsFolder = System.getProperty("selenide.download.folder"); + Environment.initPages(cfg.pagesPackage()); + } + + + public static void startApp() { + Configurations conf = ConfigFactory.create(Configurations.class, System.getProperties(), + System.getenv()); + String standParam = conf.getStand(); + Stand stand = Stand.getByName(standParam); + Selenide.open("https://" + stand.getUrlPath()); + } +} diff --git a/src/main/java/ru/lanit/at/utils/selenide/command/Commands.java b/src/main/java/ru/lanit/at/utils/selenide/command/Commands.java new file mode 100644 index 0000000..e402ce2 --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/selenide/command/Commands.java @@ -0,0 +1,45 @@ +package ru.lanit.at.utils.selenide.command; + +import com.codeborne.selenide.Command; +import com.codeborne.selenide.Condition; +import com.codeborne.selenide.SelenideElement; +import com.codeborne.selenide.ex.ElementShould; +import com.codeborne.selenide.ex.ElementShouldNot; +import org.aeonbits.owner.ConfigFactory; +import ru.lanit.at.assertion.AssertsManager; +import ru.lanit.at.utils.web.properties.Configurations; + +import java.time.Duration; + +/** + * Кастомные команды selenide элемента + */ +public class Commands { + + public static final Configurations conf = ConfigFactory.create(Configurations.class, System.getProperties(), + System.getenv()); + + private Commands() { + throw new IllegalStateException("Utility class"); + } + + /** + * Мягкая проверка Conditions element + * + * @param condition - тип проверки + * @param duration - таймаут ожидания (может быть null, в таком случае будет использоваться параметр из конфига webdriver.timeoutSeconds ) + * @return - proxy элемент + */ + + public static Command checkSoft(Condition condition, Duration duration) { + return (proxy, locator, args) -> { + try { + return proxy.should(condition, duration); + } catch (ElementShould | ElementShouldNot ex) { + AssertsManager.getAssertsManager().softAssert().fail(ex.getMessage(), ex); + } + return proxy; + }; + } + +} diff --git a/src/main/java/ru/lanit/at/utils/selenide/extensions/CustomCommands.java b/src/main/java/ru/lanit/at/utils/selenide/extensions/CustomCommands.java new file mode 100644 index 0000000..37caf60 --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/selenide/extensions/CustomCommands.java @@ -0,0 +1,19 @@ +package ru.lanit.at.utils.selenide.extensions; + +import com.codeborne.selenide.commands.Commands; +import com.codeborne.selenide.impl.WebElementSource; + +import javax.annotation.Nullable; +import javax.annotation.ParametersAreNonnullByDefault; +import java.io.IOException; + +/** Класс для переопределения и добавление методов Selenide элемента */ +@ParametersAreNonnullByDefault +public class CustomCommands + extends Commands { + @Nullable + @Override + public T execute(Object proxy, WebElementSource webElementSource, String methodName, @Nullable Object[] args) throws IOException { + return super.execute(proxy, webElementSource, methodName, args); + } +} diff --git a/src/main/java/ru/lanit/at/utils/selenide/extensions/CustomSelenidePageFactory.java b/src/main/java/ru/lanit/at/utils/selenide/extensions/CustomSelenidePageFactory.java new file mode 100644 index 0000000..c64d886 --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/selenide/extensions/CustomSelenidePageFactory.java @@ -0,0 +1,7 @@ +package ru.lanit.at.utils.selenide.extensions; + +import com.codeborne.selenide.impl.SelenidePageFactory; + +/** Класс для переопределения стандартной фабрики иницализации Page объектов */ +public class CustomSelenidePageFactory extends SelenidePageFactory { +} diff --git a/src/main/java/ru/lanit/at/utils/web/annotations/Name.java b/src/main/java/ru/lanit/at/utils/web/annotations/Name.java new file mode 100644 index 0000000..64e9f19 --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/web/annotations/Name.java @@ -0,0 +1,15 @@ +package ru.lanit.at.utils.web.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Аннотация для элементов страницы, служащая для их индентификации в cucumber-сценариях + */ +@Target({ElementType.FIELD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Name { + String value(); +} diff --git a/src/main/java/ru/lanit/at/utils/web/pagecontext/Environment.java b/src/main/java/ru/lanit/at/utils/web/pagecontext/Environment.java new file mode 100644 index 0000000..c362475 --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/web/pagecontext/Environment.java @@ -0,0 +1,64 @@ +package ru.lanit.at.utils.web.pagecontext; + + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Arrays; + +/** + * Используется для хранения кеша страниц и драйвера + */ +public class Environment { + + private static final Logger LOGGER = LoggerFactory.getLogger(Environment.class); + + + /** + * Список веб-страниц, заданных пользователем, доступных для использования в сценарии + */ + private static PageCache pages; + + /** + * Метод ищет классы, аннотированные "ru.lanit.ru.lanit.at.utils.web.annotations.Name", + * добавляя ссылки на эти классы в поле "pages" + * + * @param packageName наименование пакета где лежат файлы с описанием страниц + */ + @SuppressWarnings("unchecked") + public static void initPages(String packageName) { + pages = new PageCache(); + ru.lanit.at.utils.reflections.ReflectionUtil + .getPagesAnnotatedWith(packageName, ru.lanit.at.utils.web.annotations.Name.class) + .stream() + .map(it -> { + if (WebPage.class.isAssignableFrom(it)) { + return (Class) it; + } else { + throw new IllegalStateException("Класс " + it.getName() + " должен наследоваться от WebPage"); + } + }) + .forEach(clazz -> pages.put(getClassAnnotationValue(clazz), clazz)); + } + + /** + * Вспомогательный метод, получает значение аннотации "ru.absolut.annotations.Name" для класса + */ + private static String getClassAnnotationValue(Class c) { + return Arrays + .stream(c.getAnnotationsByType(ru.lanit.at.utils.web.annotations.Name.class)) + .findFirst() + .map(ru.lanit.at.utils.web.annotations.Name::value) + .orElseThrow(() -> new AssertionError("Не найдены аннотации Page.Name в классe " + c.getName())); + } + + public static WebPage getPage(String name) { + return pages.get(name); + } + + public static WebPage getPage(Class c) { + return getPage(getClassAnnotationValue(c)); + } + + +} \ No newline at end of file diff --git a/src/main/java/ru/lanit/at/utils/web/pagecontext/PageCache.java b/src/main/java/ru/lanit/at/utils/web/pagecontext/PageCache.java new file mode 100644 index 0000000..5c170bb --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/web/pagecontext/PageCache.java @@ -0,0 +1,59 @@ +package ru.lanit.at.utils.web.pagecontext; + +import com.codeborne.selenide.Selenide; +import com.google.common.collect.Maps; + +import java.util.Map; + +/** + * Предназначен для хранения страниц, используемых при прогоне тестов + */ +public final class PageCache { + + /** + * Страницы, на которых будет производится тестирование < Имя, Страница > + */ + private Map> pages; + + public PageCache() { + pages = Maps.newHashMap(); + } + + /** + * Получение страницы из "pages" по имени + */ + public WebPage get(String pageName) { + return Selenide.page(getPageFromPagesByName(pageName)).initialize(); + } + + /** + * Получение страницы по классу + */ + @SuppressWarnings("unchecked") + public T get(Class clazz, String name) { + WebPage webPage = Selenide.page(getPageFromPagesByName(name)).initialize(); + if (!clazz.isInstance(webPage)) { + throw new IllegalStateException(name + " page is not a instance of " + clazz + ". Named page is a " + webPage); + } + return (T) webPage; + } + + private Map> getPageMapInstanceInternal() { + return pages; + } + + private Class getPageFromPagesByName(String pageName) throws IllegalArgumentException { + Class page = getPageMapInstanceInternal().get(pageName); + if (page == null) { + throw new IllegalArgumentException("Страница с именем '" + pageName + "' не задекларирована"); + } + return page; + } + + public void put(String pageName, Class page) { + if (page == null) { + throw new IllegalArgumentException("Была передана пустая страница"); + } + pages.put(pageName, page); + } +} \ No newline at end of file diff --git a/src/main/java/ru/lanit/at/utils/web/pagecontext/PageManager.java b/src/main/java/ru/lanit/at/utils/web/pagecontext/PageManager.java new file mode 100644 index 0000000..cef74f8 --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/web/pagecontext/PageManager.java @@ -0,0 +1,30 @@ +package ru.lanit.at.utils.web.pagecontext; + +/** + * Класс, который хранит текущую страницу теста + */ +public class PageManager { + + private WebPage currentPage; + + public PageManager() { + } + + /** + * Возвращает текущую страницу, на которой в текущий момент производится тестирование + */ + public WebPage getCurrentPage() { + if (currentPage == null) { + throw new IllegalStateException("Текущая страница не задана"); + } + return currentPage.initialize(); + } + + /** + * Задает текущую страницу по ее имени + */ + public void setCurrentPage(WebPage webPage) { + this.currentPage = webPage; + } + +} \ No newline at end of file diff --git a/src/main/java/ru/lanit/at/utils/web/pagecontext/WebPage.java b/src/main/java/ru/lanit/at/utils/web/pagecontext/WebPage.java new file mode 100644 index 0000000..c7e1875 --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/web/pagecontext/WebPage.java @@ -0,0 +1,126 @@ +package ru.lanit.at.utils.web.pagecontext; + +import com.codeborne.selenide.ElementsCollection; +import com.codeborne.selenide.SelenideElement; +import org.aeonbits.owner.ConfigFactory; +import ru.lanit.at.utils.reflections.ReflectionUtil; +import ru.lanit.at.utils.web.annotations.Name; +import ru.lanit.at.utils.web.properties.Configurations; + +import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static java.lang.String.format; +import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toMap; + +public abstract class WebPage { + + protected final Configurations configurations = ConfigFactory.create(Configurations.class, System.getProperties(), + System.getenv()); + + + /** + * Список всех элементов страницы + */ + private Map namedElements; + + /** + * @param name Annotations.Name + * Возвращает объект SelenideElement по его имени (аннотированного "Annotations.Name") + */ + public SelenideElement getElement(String name) { + Object instance = namedElements.get(name); + if (instance != null && !(instance instanceof SelenideElement)) { + throw new ClassCastException(String.format("Элемент [%s] должен иметь тип 'SelenideElement'", name)); + } + return (SelenideElement) Optional.ofNullable(namedElements.get(name)) + .orElseThrow(() -> new IllegalArgumentException(String.format("Элемент [%s] отсутствует в классе [%s]", name, this.getClass().getName()))); + } + + /** + * @param name Annotations.Name + * @return Возвращает объект ElementsCollection по его имени (аннотированного "Annotations.Name") + */ + public ElementsCollection getElementsCollection(String name) { + Object instance = namedElements.get(name); + if (instance != null && !(instance instanceof ElementsCollection)) { + throw new ClassCastException(String.format("Элемент [%s] должен иметь тип 'ElementsCollection'", name)); + } + return (ElementsCollection) Optional.ofNullable(namedElements.get(name)) + .orElseThrow(() -> new IllegalArgumentException(String.format("Элемент [%s] отсутствует в классе [%s]", name, this.getClass().getName()))); + } + + /** + * @return Возвращает значение аннотации @Name(...) текущей страницы + */ + public String name() { + return this + .getClass() + .getAnnotation(Name.class) + .value(); + } + + public WebPage initialize() { + namedElements = readNamedElements(); + return this; + } + + /** + * Поиск и инициализации элементов страницы + */ + private Map readNamedElements() { + checkNamedAnnotations(); + return Arrays.stream(getClass().getDeclaredFields()) + .filter(f -> f.getDeclaredAnnotation(Name.class) != null) + .peek(this::checkFieldType) + .collect(toMap(f -> f.getDeclaredAnnotation(Name.class).value(), this::extractFieldValueViaReflection)); + } + + private void checkFieldType(Field f) { + if (!SelenideElement.class.isAssignableFrom(f.getType()) + && !WebPage.class.isAssignableFrom(f.getType()) + ) { + this.checkCollectionFieldType(f); + } + } + + private void checkCollectionFieldType(Field f) { + if (ElementsCollection.class.isAssignableFrom(f.getType())) { + return; + } else if (List.class.isAssignableFrom(f.getType())) { + ParameterizedType listType = (ParameterizedType) f.getGenericType(); + Class listClass = (Class) listType.getActualTypeArguments()[0]; + if (SelenideElement.class.isAssignableFrom(listClass) || WebPage.class.isAssignableFrom(listClass)) { + return; + } + } + throw new IllegalStateException( + format("Поле с аннотацией '@Name' должно иметь тип SelenideElement, List или ElementsCollection.\n" + + "Найдено поле с типом %s", f.getType())); + } + + /** + * Поиск по аннотации "Annotations.Name" + */ + private void checkNamedAnnotations() { + List list = Arrays.stream(getClass().getDeclaredFields()) + .filter(f -> f.getDeclaredAnnotation(Name.class) != null) + .map(f -> f.getDeclaredAnnotation(Name.class).value()) + .collect(toList()); + if (list.size() != new HashSet<>(list).size()) { + throw new IllegalStateException("Найдено несколько аннотаций '@Name' с одинаковым значением в классе " + this.getClass().getName()); + } + } + + private Object extractFieldValueViaReflection(Field field) { + return ReflectionUtil.extractFieldValue(field, this); + } + + +} diff --git a/src/main/java/ru/lanit/at/utils/web/properties/Configurations.java b/src/main/java/ru/lanit/at/utils/web/properties/Configurations.java new file mode 100644 index 0000000..49dafdd --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/web/properties/Configurations.java @@ -0,0 +1,40 @@ +package ru.lanit.at.utils.web.properties; + +import org.aeonbits.owner.Config; + + +@Config.LoadPolicy(Config.LoadType.MERGE) +@Config.Sources({ + "classpath:config/configuration.properties", + "system:properties", + "system:env" +}) +public interface Configurations extends Config { + + + @Key("stand") + @DefaultValue("GOOGLE") + String getStand(); + + + @Key("screen_after_step") + @DefaultValue("false") + boolean screenAfterStep(); + + @Key("remoteUrl") + @DefaultValue("") + String getRemoteURL(); + + @Key("enableVNC") + @DefaultValue("false") + boolean getEnableVNC(); + + @Key("enableVideo") + @DefaultValue("false") + boolean getEnableVideo(); + + @Key("enableLog") + @DefaultValue("false") + boolean getEnableLog(); + +} diff --git a/src/main/java/ru/lanit/at/utils/web/properties/WebConfigurations.java b/src/main/java/ru/lanit/at/utils/web/properties/WebConfigurations.java new file mode 100644 index 0000000..82c11d0 --- /dev/null +++ b/src/main/java/ru/lanit/at/utils/web/properties/WebConfigurations.java @@ -0,0 +1,52 @@ +package ru.lanit.at.utils.web.properties; + +import org.aeonbits.owner.Config; + +@Config.LoadPolicy(Config.LoadType.MERGE) +@Config.Sources({ + "classpath:config/${browser}.properties", + "classpath:config/chrome.properties", + "system:properties", + "system:env" +}) +public interface WebConfigurations extends Config { + + @Key("webdriver.browser.size") + @DefaultValue("1920x1080") + String webDriverBrowserSize(); + + @Key("webdriver.version") + @DefaultValue("") + String webDriverVersion(); + + @Key("webdriver.browser.name") + @DefaultValue("") + String webDriverBrowserName(); + + @Key("webdriver.timeoutSeconds") + @DefaultValue("10") + int webDriverTimeoutSeconds(); + + + @Key("webdriver.pageLoadTimeoutSeconds") + @DefaultValue("120") + int pageLoadTimeoutSeconds(); + + @Key("selenoid.enableVNC") + @DefaultValue("false") + boolean enableVnc(); + + @Key("polling.timeoutMs") + @DefaultValue("200") + int pollingTimeoutMs(); + + @Key("pages.package") + @DefaultValue("ru.lanit.at.pages") + String pagesPackage(); + + @Key("site_url") + @DefaultValue("") + String site_url(); + + +} diff --git a/src/main/resources/META-INF/aop.xml b/src/main/resources/META-INF/aop.xml new file mode 100644 index 0000000..73852e4 --- /dev/null +++ b/src/main/resources/META-INF/aop.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/main/resources/META-INF/defaultservices/com.codeborne.selenide.commands.Commands b/src/main/resources/META-INF/defaultservices/com.codeborne.selenide.commands.Commands new file mode 100644 index 0000000..c4e0308 --- /dev/null +++ b/src/main/resources/META-INF/defaultservices/com.codeborne.selenide.commands.Commands @@ -0,0 +1 @@ +ru.lanit.at.utils.selenide.extensions.CustomCommands diff --git a/src/main/resources/META-INF/defaultservices/com.codeborne.selenide.impl.PageObjectFactory b/src/main/resources/META-INF/defaultservices/com.codeborne.selenide.impl.PageObjectFactory new file mode 100644 index 0000000..7dd82e8 --- /dev/null +++ b/src/main/resources/META-INF/defaultservices/com.codeborne.selenide.impl.PageObjectFactory @@ -0,0 +1 @@ +ru.lanit.at.utils.selenide.extensions.CustomSelenidePageFactory diff --git a/src/main/resources/META-INF/services/io.qameta.allure.listener.StepLifecycleListener b/src/main/resources/META-INF/services/io.qameta.allure.listener.StepLifecycleListener new file mode 100644 index 0000000..b3091b5 --- /dev/null +++ b/src/main/resources/META-INF/services/io.qameta.allure.listener.StepLifecycleListener @@ -0,0 +1 @@ +ru.lanit.at.utils.allure.AllureLogger \ No newline at end of file diff --git a/src/main/resources/META-INF/services/io.qameta.allure.listener.TestLifecycleListener b/src/main/resources/META-INF/services/io.qameta.allure.listener.TestLifecycleListener new file mode 100644 index 0000000..b3091b5 --- /dev/null +++ b/src/main/resources/META-INF/services/io.qameta.allure.listener.TestLifecycleListener @@ -0,0 +1 @@ +ru.lanit.at.utils.allure.AllureLogger \ No newline at end of file diff --git a/src/main/resources/log4j.properties b/src/main/resources/log4j.properties new file mode 100644 index 0000000..ed36750 --- /dev/null +++ b/src/main/resources/log4j.properties @@ -0,0 +1,5 @@ +log4j.rootLogger=INFO, stdout +log4j.appender.stdout=org.apache.log4j.ConsoleAppender +log4j.appender.stdout.Target=System.out +log4j.appender.stdout.layout=org.apache.log4j.PatternLayout +log4j.appender.stdout.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n \ No newline at end of file diff --git a/src/test/java/Runner.java b/src/test/java/Runner.java new file mode 100644 index 0000000..1958636 --- /dev/null +++ b/src/test/java/Runner.java @@ -0,0 +1,22 @@ +import io.cucumber.testng.AbstractTestNGCucumberTests; +import io.cucumber.testng.CucumberOptions; +import org.testng.annotations.DataProvider; + + +@CucumberOptions( + plugin = { + "pretty", + "io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm" + }, + features = "classpath:features", + glue = {"ru.lanit.at.steps", "ru.lanit.at.hooks", "ru.lanit.at.corecommonstep"} +) +public class Runner extends AbstractTestNGCucumberTests { + + @Override + @DataProvider(parallel = true) + + public Object[][] scenarios() { + return super.scenarios(); + } +} diff --git a/src/test/resources/config/chrome.properties b/src/test/resources/config/chrome.properties new file mode 100644 index 0000000..ae9a6dc --- /dev/null +++ b/src/test/resources/config/chrome.properties @@ -0,0 +1,5 @@ +webdriver.browser.size=1920x1080 +webdriver.browser.name=chrome +webdriver.timeoutSeconds=60 +polling.timeoutMs=200 +webdriver.version=99.0 diff --git a/src/test/resources/config/configuration.properties b/src/test/resources/config/configuration.properties new file mode 100644 index 0000000..a83e4e6 --- /dev/null +++ b/src/test/resources/config/configuration.properties @@ -0,0 +1,10 @@ +stand=google +screen_after_step=false +baseUrl=https://petstore.swagger.io/v2/ + +## selenoid options +#remoteUrl=127.0.0.1:4444 +remoteUrl= +enableVNC=true +enableVideo=true +enableLog=true \ No newline at end of file diff --git a/src/test/resources/config/edge.properties b/src/test/resources/config/edge.properties new file mode 100644 index 0000000..99a126d --- /dev/null +++ b/src/test/resources/config/edge.properties @@ -0,0 +1,6 @@ +webdriver.browser.size=1920x1080 +webdriver.browser.name=edge +webdriver.timeoutSeconds=10 +polling.timeoutMs=200 +webdriver.version=91.0 + diff --git a/src/test/resources/config/firefox.properties b/src/test/resources/config/firefox.properties new file mode 100644 index 0000000..8ffe5c5 --- /dev/null +++ b/src/test/resources/config/firefox.properties @@ -0,0 +1,6 @@ +webdriver.browser.size=1920x1080 +webdriver.browser.name=firefox +webdriver.timeoutSeconds=10 +polling.timeoutMs=200 +webdriver.version=98.0 + diff --git a/src/test/resources/cucumber.properties b/src/test/resources/cucumber.properties new file mode 100644 index 0000000..aa51b35 --- /dev/null +++ b/src/test/resources/cucumber.properties @@ -0,0 +1 @@ +cucumber.publish.quiet=true \ No newline at end of file diff --git a/src/test/resources/features/api/Books.feature b/src/test/resources/features/api/Books.feature new file mode 100644 index 0000000..e730678 --- /dev/null +++ b/src/test/resources/features/api/Books.feature @@ -0,0 +1,25 @@ +#language:ru +@test + +Функционал: Образец GET запроса + + Сценарий: Запрос в bookstore к книге Just Spring Integration + + Когда создать контекстные переменные + | book | Just Spring Integration | + + И создать запрос + | method | url | + | GET | https://api.itbook.store/1.0/search/${book} | + Тогда отправить запрос + И статус код 200 + + Когда извлечь данные + | title | $.books[?(@.title=='${book}')].title | + | subtitle | $.books[?(@.title=='${book}')].subtitle | + | price | $.books[?(@.title=='${book}')].price | + + И сравнить значения + | ${title} | == | Just Spring Integration | + | ${subtitle} | == | A Lightweight Introduction to Spring Integration | + | ${price} | содержит | 16.99 | \ No newline at end of file diff --git a/src/test/resources/features/api/Github.feature b/src/test/resources/features/api/Github.feature new file mode 100644 index 0000000..263474e --- /dev/null +++ b/src/test/resources/features/api/Github.feature @@ -0,0 +1,31 @@ +#language:ru +@test + +Функционал: Selenide github test + - Выполнение запроса в репозиторий selenide + - Проверка что статус-код = 200 + - Извлечение данных из тела ответа по jsonpath + - Проверка извлеченных данных + + Сценарий: Выполнение GET запроса в репозиторий selenide + + * создать запрос + | method | url | + | GET | https://api.github.com/orgs/selenide/repos | + * добавить header + | Accept | application/vnd.github.v3+json | + * отправить запрос + * статус код 200 + * извлечь данные + | name | $[?(@.full_name=='selenide/selenide')].name | + | id | $[?(@.full_name=='selenide/selenide')].id | + | language | $[?(@.full_name=='selenide/selenide')].language | + | homepage | $[?(@.full_name=='selenide/selenide')].homepage | + | size | $[?(@.full_name=='selenide/selenide')].size | + + * сравнить значения + | ${name} | == | selenide | + | ${id} | != | null | + | ${language} | == | Java | + | ${homepage} | == | http://selenide.org | + | ${size} | > | 0 | \ No newline at end of file diff --git a/src/test/resources/features/api/PetStore.feature b/src/test/resources/features/api/PetStore.feature new file mode 100644 index 0000000..1ecd393 --- /dev/null +++ b/src/test/resources/features/api/PetStore.feature @@ -0,0 +1,56 @@ +#language:ru +@test + +Функционал: Тестирование сервиса PetStore + - Создание нового юзера POST запросом с телом из json файла, значения которого заполняем сгенерированным значениями + - После создания нового юзера, GET запросом запрашиваем данного юзера и проверяем, что его данные соответствует данными из тела запроса + + Сценарий: Создание юзера + + + # Первая часть теста - Создание юзера. Эти данные подставятся в тело запроса в шаблон тела файла createUser.json + # Генерится дандомная страка по маске + # E - Английская буква, + # R - русская буква, + # D - цифра. Остальные символы игнорятся + # Условна дана строка TEST_EEE_DDD_RRR - снегерится примерно такая - TEST_QRG_904_ЙЦУ + * сгенерировать переменные + | id | 0 | + | username | EEEEEEEE | + | firstName | EEEEEEEE | + | lastName | EEEEEEEE | + | email | EEEEEEE@EEEDDD.EE | + | password | DDDEEEDDDEEE | + | phone | +7DDDDDDDDDD | + | userStatus | 1 | + + # Создаем юзера + * создать запрос + | method | path | body | + | POST | /user | createUser.json | + * добавить header + | Content-Type | application/json | + * отправить запрос + * статус код 200 + * извлечь данные + | user_id | $.message | + * сравнить значения + | ${user_id} | != | null | + + # Вторая часть теста - запрос юзера и проверка его данных + * создать запрос + | method | path | + | GET | /user/${username} | + * добавить header + | accept | application/json | + + # FLAKY - Из-за особенностей сервиса PetStore может возвращать 404 + * отправить запрос + * статус код 200 + * извлечь данные + | resp_firstname | $.firstName | + | resp_user_id | $.id | + + * сравнить значения + | ${user_id} | == | ${resp_user_id} | + | ${firstName} | == | ${resp_firstname} | \ No newline at end of file diff --git a/src/test/resources/features/web/Example.feature b/src/test/resources/features/web/Example.feature new file mode 100644 index 0000000..c472e51 --- /dev/null +++ b/src/test/resources/features/web/Example.feature @@ -0,0 +1,18 @@ +#language:ru +@google +Функционал: Поиск гугл + + Сценарий: Открытие страницы google.com, ввод значения в поиск + + * шаг № "1" + * открыть url "https://www.google.ru/" + * инициализация страницы "Google" + * ввести в поле "поле поиска" значение "Погода в Москве" + + * на странице отсутствует текст "погода в ижевске" + + * шаг № "2" + * на странице имеется элемент "кнопка поиска" + * кликнуть на элемент "кнопка поиска" + * переход на страницу "Google страница результатов" + * на странице имеется элемент "виджет погоды" \ No newline at end of file diff --git a/src/test/resources/features/web/FragmentExample.feature b/src/test/resources/features/web/FragmentExample.feature new file mode 100644 index 0000000..7c7bbd5 --- /dev/null +++ b/src/test/resources/features/web/FragmentExample.feature @@ -0,0 +1,24 @@ +#language:ru +@googleFragment +Функционал: Поиск гугл + + Сценарий: Открытие двух фрагментов - страницы google.com и виджета погоды + + * шаг № "1" + * вызвать фрагмент "открытие страницы google" + + * на странице отсутствует текст "погода в ижевске" + + * шаг № "2" + * вызвать фрагмент "открытие виджета" + + @googleDynamicFragment + Сценарий: Открытие двух фрагментов - страницы google.com и виджета погоды с использованным динамического фрагмента + * шаг № "1" + * вызвать фрагмент "открытие страницы и ввод текста из параметра" + | текст_для_ввода | погода в москве | + + * на странице отсутствует текст "погода в ижевске" + + * шаг № "2" + * вызвать фрагмент "открытие виджета" \ No newline at end of file diff --git a/src/test/resources/json/createUser.json b/src/test/resources/json/createUser.json new file mode 100644 index 0000000..4027892 --- /dev/null +++ b/src/test/resources/json/createUser.json @@ -0,0 +1,10 @@ +{ + "id": ${id}, + "username": "${username}", + "firstName": "${firstName}", + "lastName": "${lastName}", + "email": "${email}", + "password": "${password}", + "phone": "${phone}", + "userStatus": ${userStatus} +} \ No newline at end of file diff --git a/src/test/resources/log4j.properties b/src/test/resources/log4j.properties new file mode 100644 index 0000000..ed36750 --- /dev/null +++ b/src/test/resources/log4j.properties @@ -0,0 +1,5 @@ +log4j.rootLogger=INFO, stdout +log4j.appender.stdout=org.apache.log4j.ConsoleAppender +log4j.appender.stdout.Target=System.out +log4j.appender.stdout.layout=org.apache.log4j.PatternLayout +log4j.appender.stdout.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n \ No newline at end of file diff --git a/src/test/resources/suite.xml b/src/test/resources/suite.xml new file mode 100644 index 0000000..00550b5 --- /dev/null +++ b/src/test/resources/suite.xml @@ -0,0 +1,9 @@ + + + + + + + + +