From 950fc76efd3136dcc2d926709e65725f7854d983 Mon Sep 17 00:00:00 2001 From: Anastasia Katsman Date: Wed, 26 Jun 2024 20:25:33 +0000 Subject: [PATCH] [driver-tests]: AT-1003: add logging, add waiters API, add javadocs Merge-request: IJ-MR-137133 Merged-by: Anastasia Katsman GitOrigin-RevId: dbb248292e74c0d1c0aed375538c4077e7b71440 --- .../intellij/driver/sdk/DaemonCodeAnalyzer.kt | 2 +- .../src/com/intellij/driver/sdk/editors.kt | 2 +- .../src/com/intellij/driver/sdk/indicators.kt | 4 +- .../src/com/intellij/driver/sdk/startup.kt | 2 +- .../src/com/intellij/driver/sdk/ui/Finder.kt | 12 +- .../src/com/intellij/driver/sdk/ui/UiText.kt | 16 + .../driver/sdk/ui/components/BookmarksUi.kt | 2 +- .../driver/sdk/ui/components/Dialog.kt | 2 +- .../driver/sdk/ui/components/JCefUI.kt | 12 +- .../driver/sdk/ui/components/JTreeUI.kt | 4 +- .../sdk/ui/components/NavigationBarUi.kt | 2 +- .../driver/sdk/ui/components/PopupUI.kt | 9 +- .../ui/components/SearchEverywherePopupUI.kt | 3 +- .../sdk/ui/components/StripeToolbarUi.kt | 2 +- .../sdk/ui/components/UIComponentsList.kt | 58 ++- .../driver/sdk/ui/components/UiComponent.kt | 335 +++++++++++++----- .../com/intellij/driver/sdk/ui/conditions.kt | 31 +- .../driver/sdk/ui/keyboard/RemoteKeyboard.kt | 57 ++- .../src/com/intellij/driver/sdk/ui/utils.kt | 13 + .../src/com/intellij/driver/sdk/waits.kt | 163 ++++++--- 20 files changed, 547 insertions(+), 184 deletions(-) diff --git a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/DaemonCodeAnalyzer.kt b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/DaemonCodeAnalyzer.kt index 5efb4dffdd65..57469f04f9c8 100644 --- a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/DaemonCodeAnalyzer.kt +++ b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/DaemonCodeAnalyzer.kt @@ -33,7 +33,7 @@ fun Driver.isCodeAnalysisFinished(project: Project? = null, file: VirtualFile): fun Driver.waitForCodeAnalysis(project: Project? = null, file: VirtualFile, timeout: Duration = 1.minutes) { withContext { - waitFor(timeout) { + waitFor("No Code analysis", timeout) { isCodeAnalysisFinished(project, file) } } diff --git a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/editors.kt b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/editors.kt index 6e51be135495..700ec512dd84 100644 --- a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/editors.kt +++ b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/editors.kt @@ -110,7 +110,7 @@ fun Driver.openFile(relativePath: String, project: Project = singleProject(), wa val service = service(GuestNavigationService::class, project) withContext(OnDispatcher.EDT) { service.navigateViaBackend(relativePath, 0) - waitFor(errorMessage = "Fail to open file $relativePath", duration = 30.seconds, + waitFor(message = "File is opened: $relativePath", timeout = 30.seconds, getter = { service(project).getSelectedTextEditor()?.getVirtualFile() }, diff --git a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/indicators.kt b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/indicators.kt index 9b8f1a8b7e91..7f6bb1f60dc9 100644 --- a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/indicators.kt +++ b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/indicators.kt @@ -21,7 +21,7 @@ fun Driver.areIndicatorsVisible(project: Project): Boolean { } fun Driver.waitForProjectOpen(timeout: Duration = 1.minutes) { - waitFor(timeout) { + waitFor("Project is opened", timeout) { isProjectOpened() } } @@ -37,7 +37,7 @@ fun Driver.waitForIndicators(timeout: Duration) { private fun Driver.waitForIndicators(projectGet: () -> Project, timeout: Duration) { var smartLongEnoughStart: Instant? = null - waitFor(timeout) { + waitFor("Indicators", timeout) { if (!isProjectOpened() || runCatching { areIndicatorsVisible(projectGet.invoke()) }.getOrElse { false }) { smartLongEnoughStart = null return@waitFor false diff --git a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/startup.kt b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/startup.kt index 7b786e14b070..27fcb100b080 100644 --- a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/startup.kt +++ b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/startup.kt @@ -16,7 +16,7 @@ interface FUSProjectHotStartUpMeasurerService { * This is util method for such enforcement */ fun Driver.waitForStartupFUSToWrite() { - waitFor(duration = 10.seconds, interval = 1.seconds) { + waitFor("Fus startup is finished", timeout = 10.seconds, interval = 1.seconds) { service(FUSProjectHotStartUpMeasurerService::class).isHandlingFinished() } } \ No newline at end of file diff --git a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/Finder.kt b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/Finder.kt index 2a3c04913d28..adc63f99a5ed 100644 --- a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/Finder.kt +++ b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/Finder.kt @@ -8,8 +8,9 @@ import com.intellij.driver.sdk.ui.remote.Component import com.intellij.driver.sdk.ui.remote.RobotProvider import com.intellij.driver.sdk.ui.remote.SearchService import org.intellij.lang.annotations.Language +import kotlin.time.Duration.Companion.seconds -internal const val DEFAULT_FIND_TIMEOUT_SECONDS = 15 +internal val DEFAULT_FIND_TIMEOUT = 15.seconds interface Finder { val driver: Driver @@ -20,6 +21,9 @@ interface Finder { val isRemoteIdeMode: Boolean get() = driver.isRemoteIdeMode + /** + * Creates UiComponent, the actual component will be requested lazily. + */ fun x(@Language("xpath") xpath: String): UiComponent { return UiComponent(ComponentData(xpath, driver, searchService, robotProvider, searchContext, null)) } @@ -38,6 +42,9 @@ interface Finder { ).newInstance(ComponentData(xpath, driver, searchService, robotProvider, searchContext, null)) } + /** + * Provides a list of UI components based on the given XPath. Actual components list is requested lazily. + */ fun xx(@Language("xpath") xpath: String): UIComponentsList { return UIComponentsList(xpath, UiComponent::class.java, driver, searchService, robotProvider, searchContext) } @@ -53,5 +60,8 @@ interface Finder { interface SearchContext { val context: String + val contextAsString: String + get() = if (context == "") "global context" else context + fun findAll(xpath: String): List } \ No newline at end of file diff --git a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/UiText.kt b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/UiText.kt index a567aaa6ff94..d2e8edfac66b 100644 --- a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/UiText.kt +++ b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/UiText.kt @@ -2,24 +2,40 @@ package com.intellij.driver.sdk.ui import com.intellij.driver.model.TextData import com.intellij.driver.sdk.ui.components.UiComponent +import com.intellij.openapi.diagnostic.logger class UiText(private val component: UiComponent, private val textData: TextData) { + companion object { + private val LOG get() = logger() + + fun List.allText(separator: String = "") = joinToString(separator) { it.text } + } + val text = textData.text val point = textData.point val bundleKey = textData.bundleKey + + override fun toString(): String { + return "UiText[point=${point},text=$text]" + } + fun click() { + LOG.info("Click at '${text}'") component.click(textData.point) } fun doubleClick() { + LOG.info("Double click at '${text}'") component.doubleClick(textData.point) } fun rightClick() { + LOG.info("Right click at '${text}'") component.rightClick(textData.point) } fun moveMouse() { + LOG.info("Move mouse to ${textData.point}") component.moveMouse(textData.point) } } \ No newline at end of file diff --git a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/BookmarksUi.kt b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/BookmarksUi.kt index 0c8937db7531..9af6c332352e 100644 --- a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/BookmarksUi.kt +++ b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/BookmarksUi.kt @@ -74,5 +74,5 @@ class BookmarksPopupUiComponent(data: ComponentData) : UiComponent(data) { .map { it.toString().replace("TreePathToRow{path=[", "").dropLast(2) } fun clickBookmark(textContains: String, doubleClick: Boolean = false) = - bookmarksTree.findAllText().first { it.text.contains(textContains) }.apply { if (doubleClick) doubleClick() else click() } + bookmarksTree.waitSomeTextsContains(text = textContains).first().apply { if (doubleClick) doubleClick() else click() } } \ No newline at end of file diff --git a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/Dialog.kt b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/Dialog.kt index 100abf353191..ad862c128157 100644 --- a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/Dialog.kt +++ b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/Dialog.kt @@ -26,7 +26,7 @@ fun Finder.dialog(@Language("xpath") xpath: String? = null, action: DialogUiComp x(xpath ?: "//div[@class='MyDialog']", DialogUiComponent::class.java).action() fun Finder.waitForNoOpenedDialogs() { - waitFor(errorMessage = "Dialog is still opened", duration = 100.seconds) { + waitFor(message = "Dialog is closed", timeout = 100.seconds) { !isDialogOpened() } } diff --git a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/JCefUI.kt b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/JCefUI.kt index 573e9f8803bc..6eba96d5dd25 100644 --- a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/JCefUI.kt +++ b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/JCefUI.kt @@ -3,10 +3,10 @@ package com.intellij.driver.sdk.ui.components import com.intellij.driver.client.Remote import com.intellij.driver.sdk.ui.Finder import com.intellij.driver.sdk.ui.remote.REMOTE_ROBOT_MODULE_ID -import com.intellij.driver.sdk.ui.should -import org.intellij.lang.annotations.Language +import com.intellij.driver.sdk.waitForOne import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import org.intellij.lang.annotations.Language import java.awt.Point import kotlin.math.roundToInt import kotlin.time.Duration @@ -52,12 +52,8 @@ class JCefUI(data: ComponentData) : UiComponent(data) { } fun findElement(@Language("XPath") xpath: String, wait: Duration = 5.seconds): DomElement { - var elements: List = emptyList() - should(message = "Can't find element by '$xpath' in the embedded browser($component)", 5) { - elements = findElements(xpath) - elements.size == 1 - } - return elements.single() + return waitForOne("Find element by '$xpath' in the embedded browser($component)", wait, + getter = { findElements(xpath) } ) } fun findElements(@Language("xpath") xpath: String): List { diff --git a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/JTreeUI.kt b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/JTreeUI.kt index 702ef97bc9a6..9e4fe8f52dcd 100644 --- a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/JTreeUI.kt +++ b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/JTreeUI.kt @@ -60,7 +60,7 @@ open class JTreeUiComponent(data: ComponentData) : UiComponent(data) { fixture.expandAll(timeout.inWholeMilliseconds.toInt()) } - fun expandPath(vararg path: String, fullMatch: Boolean = true) = waitFor(10.seconds, errorMessage = "Failed find ${path.toList()}") { + fun expandPath(vararg path: String, fullMatch: Boolean = true) = waitFor("Expand path '${path.toList()}'", 10.seconds) { try { val expandedPath = mutableListOf() path.forEach { @@ -81,7 +81,7 @@ open class JTreeUiComponent(data: ComponentData) : UiComponent(data) { } } - fun expandPathWithEnter(vararg path: String, fullMatch: Boolean = true) = waitFor(10.seconds, errorMessage = "Failed find ${path.toList()}") { + fun expandPathWithEnter(vararg path: String, fullMatch: Boolean = true) = waitFor("Expand path by enter '${path.toList()}'") { try { val expandedPath = mutableListOf() path.forEach { diff --git a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/NavigationBarUi.kt b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/NavigationBarUi.kt index 275a099663dd..4ccecc0e2969 100644 --- a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/NavigationBarUi.kt +++ b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/NavigationBarUi.kt @@ -12,7 +12,7 @@ class NavigationBarUi(data: ComponentData): UiComponent(data) { } val currentPath: List - get() = findAllText().sortedBy { it.point.x }.map { it.text } + get() = getAllTexts().sortedBy { it.point.x }.map { it.text } fun navBarItem(item: String): NavBarItemUi = x(NavBarItemUi::class.java) { and(byClass("NavBarItemComponent"), byAccessibleName(item)) } diff --git a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/PopupUI.kt b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/PopupUI.kt index 16573ad388ed..3542d816a7d5 100644 --- a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/PopupUI.kt +++ b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/PopupUI.kt @@ -4,9 +4,8 @@ import com.intellij.driver.client.Remote import com.intellij.driver.model.OnDispatcher import com.intellij.driver.sdk.ui.Finder import com.intellij.driver.sdk.ui.remote.Component -import com.intellij.driver.sdk.ui.should +import com.intellij.driver.sdk.waitForOne import org.intellij.lang.annotations.Language -import java.util.* import kotlin.time.Duration.Companion.seconds fun Finder.popup(@Language("xpath") xpath: String? = null) = @@ -25,8 +24,10 @@ class PopupMenuUiComponent(data: ComponentData) : UiComponent(data) { fun select(vararg items: String) { items.forEach { item -> - should(timeout = 5.seconds, message = "Fail to find items: ${items.contentToString()}") { menuItems.list().map { it.getText() }.contains(item) } - menuItems.list().first { it.getText() == item }.click() + waitForOne(message = "Find item: $item", timeout = 5.seconds, + getter = { menuItems.list() }, + checker = { it.getText() == item }) + .click() } } diff --git a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/SearchEverywherePopupUI.kt b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/SearchEverywherePopupUI.kt index 30a64f157bd5..30a675df06ff 100644 --- a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/SearchEverywherePopupUI.kt +++ b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/SearchEverywherePopupUI.kt @@ -3,6 +3,7 @@ package com.intellij.driver.sdk.ui.components import com.intellij.driver.sdk.ui.Finder import com.intellij.driver.sdk.ui.should import org.intellij.lang.annotations.Language +import kotlin.time.Duration.Companion.seconds fun Finder.searchEverywherePopup(@Language("xpath") xpath: String? = null) = x(xpath ?: "//div[@class='HeavyWeightWindow'][//div[contains(@class, 'SearchEverywhereUI')]]", @@ -14,7 +15,7 @@ class SearchEverywherePopupUI(data: ComponentData): PopupUiComponent(data) { fun searchAndChooseFirst(text: String, exactMatch: Boolean = true) { keyboard { enterText(text) - resultsList.should(15) { + resultsList.should(timeout = 15.seconds) { if (exactMatch) hasText(text) else hasSubtext(text) } enter() diff --git a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/StripeToolbarUi.kt b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/StripeToolbarUi.kt index c4c14500600d..372fb7c63d11 100644 --- a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/StripeToolbarUi.kt +++ b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/StripeToolbarUi.kt @@ -18,7 +18,7 @@ class ToolWindowToolbarUiComponent(data: ComponentData) : UiComponent(data) { fun openToolWindowFromMore(title: String) { moreButton.click() - driver.ui.popup().findAllText().first { it.text == title }.click() + driver.ui.popup().waitAnyTexts(text = title).first().click() } fun openToolWindow(title: String) { diff --git a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/UIComponentsList.kt b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/UIComponentsList.kt index 4b4cb65fde30..b22f9b5456db 100644 --- a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/UIComponentsList.kt +++ b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/UIComponentsList.kt @@ -1,19 +1,58 @@ package com.intellij.driver.sdk.ui.components import com.intellij.driver.client.Driver -import com.intellij.driver.sdk.ui.SearchContext +import com.intellij.driver.sdk.ui.* import com.intellij.driver.sdk.ui.remote.Component import com.intellij.driver.sdk.ui.remote.RobotProvider import com.intellij.driver.sdk.ui.remote.SearchService +import com.intellij.driver.sdk.waitFor +import com.intellij.openapi.diagnostic.Logger +import kotlin.time.Duration + +class UIComponentsList( + private val xpath: String, + private val type: Class, + val driver: Driver, + val searchService: SearchService, + val robotProvider: RobotProvider, + private val parentSearchContext: SearchContext, +) { + + companion object { + private val LOG get() = Logger.getInstance(UIComponentsList::class.java) + + /** + * Searches for a non-empty list of UI components based on the given XPath until timeout hits. + */ + fun Finder.waitAtLeastAny(message: String? = null, timeout: Duration = DEFAULT_FIND_TIMEOUT, init: QueryBuilder.() -> String): UIComponentsList { + return waitFor(message, timeout, + getter = { UIComponentsList(xQuery { init() }, UiComponent::class.java, driver, searchService, robotProvider, searchContext) }, + checker = { it.list().isNotEmpty() }) + } + + /** + * Waits for some ui components matching the given XPath. + */ + fun Finder.waitAtLeastAny(message: String? = null, type: Class, timeout: Duration = DEFAULT_FIND_TIMEOUT, init: QueryBuilder.() -> String): UIComponentsList { + return waitFor(message, timeout, + getter = { UIComponentsList(xQuery { init() }, type, driver, searchService, robotProvider, searchContext) }, + checker = { it.list().isNotEmpty() }) + } + + /** + * Waits until there are no ui components matching the given XPath. + */ + fun Finder.waitNotFound(message: String? = null, timeout: Duration = DEFAULT_FIND_TIMEOUT, init: QueryBuilder.() -> String): UIComponentsList { + return waitFor(message, timeout, + getter = { UIComponentsList(xQuery { init() }, UiComponent::class.java, driver, searchService, robotProvider, searchContext) }, + checker = { it.list().isEmpty() }) + } + } -class UIComponentsList(private val xpath: String, - private val type: Class, - val driver: Driver, - val searchService: SearchService, - val robotProvider: RobotProvider, - private val parentSearchContext: SearchContext) { fun list(): List { - return parentSearchContext.findAll(xpath).mapIndexed { n, c -> + LOG.info("Requesting all ${type.simpleName}(s) by xpath = $xpath") + + val components = parentSearchContext.findAll(xpath).mapIndexed { n, c -> val searchContext = object : SearchContext { override val context: String get() = parentSearchContext.context + xpath + "[$n]" @@ -26,5 +65,8 @@ class UIComponentsList(private val xpath: String, ComponentData::class.java ).newInstance(ComponentData(xpath, driver, searchService, robotProvider, searchContext, c)) } + LOG.info("Returning ${components.size} ${type.simpleName}(s) by xpath = $xpath" + + "\n${printableString(components.joinToString(", ") { it.toString() })}") + return components } } \ No newline at end of file diff --git a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/UiComponent.kt b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/UiComponent.kt index 13f621e232a7..ca2eb2b90645 100644 --- a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/UiComponent.kt +++ b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/components/UiComponent.kt @@ -2,18 +2,20 @@ package com.intellij.driver.sdk.ui.components import com.intellij.driver.client.Driver import com.intellij.driver.model.RemoteMouseButton -import com.intellij.driver.model.TextData import com.intellij.driver.sdk.screenshot.takeScreenshot -import com.intellij.driver.sdk.ui.DEFAULT_FIND_TIMEOUT_SECONDS +import com.intellij.driver.sdk.ui.DEFAULT_FIND_TIMEOUT import com.intellij.driver.sdk.ui.Finder import com.intellij.driver.sdk.ui.SearchContext import com.intellij.driver.sdk.ui.UiText +import com.intellij.driver.sdk.ui.UiText.Companion.allText import com.intellij.driver.sdk.ui.keyboard.WithKeyboard import com.intellij.driver.sdk.ui.remote.Component import com.intellij.driver.sdk.ui.remote.Robot import com.intellij.driver.sdk.ui.remote.RobotProvider import com.intellij.driver.sdk.ui.remote.SearchService import com.intellij.driver.sdk.waitFor +import com.intellij.driver.sdk.waitForOne +import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.util.SystemInfo import java.awt.Color import java.awt.Point @@ -23,14 +25,63 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -data class ComponentData(val xpath: String, - val driver: Driver, - val searchService: SearchService, - val robotProvider: RobotProvider, - val parentSearchContext: SearchContext, - val foundComponent: Component?) +data class ComponentData( + val xpath: String, + val driver: Driver, + val searchService: SearchService, + val robotProvider: RobotProvider, + val parentSearchContext: SearchContext, + val foundComponent: Component?, +) open class UiComponent(private val data: ComponentData) : Finder, WithKeyboard { + companion object { + private val LOG get() = logger() + + /** + * Waits until the element specified is found within the parent search context. Doesn't guaranty visibility. + * + * @param timeout The maximum time to wait for the element to not be found. If not specified, the default timeout is used. + */ + fun T.waitFound(timeout: Duration? = DEFAULT_FIND_TIMEOUT): T { + findThisComponent(timeout) + return this + } + + /** + * Asserts that the current UI component is found. Doesn't check visibility. + * + * @return The current UI component. + */ + fun T.assertFound(): T { + assert(present()) { "Component '$this' should be found" } + return this + } + + /** + * Waits until the element specified is not found within the parent search context. + * + * @param timeout The maximum time to wait for the element to not be found. If not specified, the default timeout is used. + */ + fun T.waitNotFound(timeout: Duration? = DEFAULT_FIND_TIMEOUT) { + waitFor(message = "No ${this::class.simpleName}[xpath=${data.xpath}] in ${data.parentSearchContext.contextAsString}", + timeout = timeout ?: DEFAULT_FIND_TIMEOUT, + interval = 1.seconds) { + kotlin.runCatching { findThisComponent(timeout) }.isFailure + } + } + /** + * Asserts that the calling UiComponent is not found in the hierarchy. + */ + fun T.assertNotFound() { + assert(!present()) { "Component '$this' should not be found" } + } + } + + override fun toString(): String { + return this::class.simpleName + "[xpath=${data.xpath}]" + } + private var cachedComponent: Component? = null val component: Component get() = data.foundComponent ?: kotlin.runCatching { cachedComponent?.takeIf { it.isShowing() } }.getOrNull() @@ -40,17 +91,13 @@ open class UiComponent(private val data: ComponentData) : Finder, WithKeyboard { robot.focus(this.component) } - private fun findThisComponent(): Component { - lateinit var result: List - var actual = 0 - waitFor(DEFAULT_FIND_TIMEOUT_SECONDS.seconds, - errorMessage = { "Can't find component with '${data.xpath}' in ${data.parentSearchContext.context.takeIf { it.isNotEmpty() } ?: "whole hierarchy"}, expected 1, but got ${actual}" }) { - result = data.parentSearchContext.findAll(data.xpath) - actual = result.size - result.size == 1 - } - return result.first() - } + private fun findThisComponent(timeout: Duration? = DEFAULT_FIND_TIMEOUT): Component = + waitForOne( + message = "Find ${this::class.simpleName}[xpath=${data.xpath}] in ${data.parentSearchContext.contextAsString}", + timeout = timeout ?: DEFAULT_FIND_TIMEOUT, + interval = 1.seconds, + getter = { data.parentSearchContext.findAll(data.xpath) } + ) override val driver: Driver = data.driver override val searchService: SearchService = data.searchService @@ -68,64 +115,164 @@ open class UiComponent(private val data: ComponentData) : Finder, WithKeyboard { } } - // Search Text Locations - fun findText(text: String): UiText { - val allTexts = findAllText() - val filteredTexts = allTexts.filter { it.text == text } - if (filteredTexts.isEmpty()) { - throw AssertionError("No '$text' found. Available texts are:\n${allTexts.joinToString("\n") { it.text }}") - } - if (filteredTexts.size > 1) { - throw AssertionError("Found ${filteredTexts.size} texts '$text', expected 1") - } - return filteredTexts.first() + /* + Returns all UiText's matching predicate without waiting + */ + fun getAllTexts(predicate: ((UiText) -> Boolean)? = null): List { + val allText = searchService.findAllText(component).map { UiText(this, it) } + return predicate?.let { allText.filter(predicate) } ?: allText } - fun findFirst(text: String, duration: Duration = DEFAULT_FIND_TIMEOUT_SECONDS.seconds): UiText { - var allTexts = emptyList() - waitFor(duration, errorMessage = "Can't find '$text' in ${data.parentSearchContext.context}") { - allTexts = findAllText() - allTexts.any { it.text == text } - } - return allTexts.first { it.text == text } + /** + * Returns all UiText objects matching the specified text without waiting. + */ + fun getAllTexts(text: String): List { + return searchService.findAllText(component).map { UiText(this, it) }.filter { it.text == text } } - fun findOne(text: String, duration: Duration = DEFAULT_FIND_TIMEOUT_SECONDS.seconds): UiText { - var allTexts = emptyList() - waitFor(duration, errorMessage = "Can't find '$text' in ${data.parentSearchContext.context}") { - allTexts = findAllText() - allTexts.any { it.text == text } - } - val filteredTexts = allTexts.filter { it.text == text } - if (filteredTexts.size > 1) { - throw AssertionError("Found ${filteredTexts.size} texts '$text', expected 1") - } - return filteredTexts.first() + fun allTextAsString(): String { + return getAllTexts().allText() } - fun findOneContainsText(text: String, ignoreCase: Boolean = true, duration: Duration = DEFAULT_FIND_TIMEOUT_SECONDS.seconds): UiText { - var allTexts = emptyList() - waitFor(duration, errorMessage = "Can't find '$text' in ${data.parentSearchContext.context}") { - allTexts = findAllText() - allTexts.any { it.text.contains(text, ignoreCase = ignoreCase) } - } - val filteredTexts = allTexts.filter { it.text.contains(text, ignoreCase = ignoreCase) } - if (filteredTexts.size > 1) { - throw AssertionError("Found ${filteredTexts.size} texts '$text', expected 1") - } - return filteredTexts.first() + /** + * Waits for a non-empty list of UiText's. + */ + fun waitAnyTexts(message: String? = null, timeout: Duration = DEFAULT_FIND_TIMEOUT): List { + return waitFor(message = message ?: "Finding at least some texts in $this", + timeout = timeout, + getter = { getAllTexts() }, + checker = { it.isNotEmpty() } + ) } + /** + * Waits for a non-empty list of UiText's matching predicate. + */ + fun waitAnyTexts(message: String? = null, timeout: Duration = DEFAULT_FIND_TIMEOUT, predicate: (UiText) -> Boolean = { true }): List { + return waitFor(message = message ?: "Finding at least some texts and filter matching predicate in $this", + timeout = timeout, + getter = { getAllTexts() }, + checker = { it.any(predicate) } + ).filter(predicate) + } + + /** + * Waits for a non-empty list of UiText's with text '$text'. + */ + fun waitAnyTexts(text: String, message: String? = null, timeout: Duration = DEFAULT_FIND_TIMEOUT): List { + return waitFor(message = message ?: "Finding at least some texts and filter '$text' in $this", + timeout = timeout, + getter = { getAllTexts() }, + checker = { it.any { it.text == text } } + ).filter { it.text == text } + } + + /** + * Waits for one UiText with text '$text'. + */ + fun waitOneText(text: String, message: String, timeout: Duration = DEFAULT_FIND_TIMEOUT): UiText { + return waitForOne(message = message, + timeout = timeout, + getter = { getAllTexts() }, + checker = { it.text == text } + ) + } + + /** + * Waits for one UiText with text '$text'. + */ + fun waitOneText(text: String, timeout: Duration = DEFAULT_FIND_TIMEOUT): UiText { + return waitOneText(message = "Finding text '$text' in $this", timeout = timeout, text = text) + } + + /** + * Waits for one UiText matching predicate. + */ + fun waitOneText(message: String? = null, timeout: Duration = DEFAULT_FIND_TIMEOUT, predicate: (UiText) -> Boolean): UiText { + return waitForOne(message = message ?: "Finding one text matching predicate in $this", + timeout = timeout, + getter = { getAllTexts() }, + checker = { predicate(it) } + ) + } + + /** + * Waits until there is no UiText's. + */ + fun waitNoTexts(message: String? = null, timeout: Duration = DEFAULT_FIND_TIMEOUT): List { + return waitFor(message = message ?: "Finding no texts in $this", + timeout = timeout, + getter = { getAllTexts() }, + checker = { it.isEmpty() } + ) + } + + /** + * Waits until there is no UiText's with text '$text'. + */ + fun waitNoTexts(text: String, message: String? = null, timeout: Duration = DEFAULT_FIND_TIMEOUT): List { + return waitFor(message = message ?: "Finding no texts '$text' in $this", + timeout = timeout, + getter = { getAllTexts() }, + checker = { it.filter { it.text == text }.isEmpty() } + ) + } + + /** + * Waits for a non-empty list of UiText's with substring '$text'. + */ + fun waitSomeTextsContains(text: String, message: String? = null, timeout: Duration = DEFAULT_FIND_TIMEOUT): List { + return waitFor(message = message ?: "Finding at least some texts and contains '$text' in $this", + timeout = timeout, + getter = { getAllTexts() }, + checker = { it.any { it.text.contains(text) } } + ) + } + + /** + * Waits until there is one UiText's with substring '$text'. + */ + fun waitOneContainsText(text: String, message: String, ignoreCase: Boolean = true, timeout: Duration = DEFAULT_FIND_TIMEOUT): UiText { + return waitForOne(message = message, + timeout = timeout, + getter = { getAllTexts() }, + checker = { it.text.contains(text, ignoreCase = ignoreCase) } + ) + } + + /** + * Waits until there is one UiText's with substring '$text'. + */ + fun waitOneContainsText(text: String, ignoreCase: Boolean = true, timeout: Duration = DEFAULT_FIND_TIMEOUT): UiText { + return waitOneContainsText(message = "Finding the text containing '$text' in $this", + timeout = timeout, text = text, ignoreCase = ignoreCase) + } + + /** + * Waits until all text contains 'text'. + */ + fun waitContainsText(text: String, message: String, ignoreCase: Boolean = true, timeout: Duration = DEFAULT_FIND_TIMEOUT) { + waitFor(message = message, + timeout = timeout, + getter = { getAllTexts().allText() }, + checker = { it.contains(text, ignoreCase = ignoreCase) } + ) + } + + /** + * Waits until all text contains 'text'. + */ + fun waitContainsText(text: String, ignoreCase: Boolean = true, timeout: Duration = DEFAULT_FIND_TIMEOUT) = + waitContainsText(message = "Finding the text containing '$text' in $this", timeout = timeout, text = text, ignoreCase = ignoreCase) + fun hasText(text: String): Boolean { - return findAllText { - it.text == text - }.isNotEmpty() + return getAllTexts(text).isNotEmpty() } fun hasTextSequence(vararg texts: String, indexOffset: Int = 0): Boolean { require(indexOffset >= 0) { "Value must be non-negative" } val stringList = texts.toList() - val uiTextList = findAllText() + val uiTextList = getAllTexts() return stringList.indices.all { index -> val uiTextIndex = index + indexOffset uiTextIndex in uiTextList.indices && stringList[index] == uiTextList[uiTextIndex].text @@ -133,14 +280,51 @@ open class UiComponent(private val data: ComponentData) : Finder, WithKeyboard { } fun hasSubtext(subtext: String): Boolean { - return findAllText { - it.text.contains(subtext) - }.isNotEmpty() + return getAllTexts { it.text.contains(subtext) }.isNotEmpty() } - fun findText(predicate: (TextData) -> Boolean): UiText { - return searchService.findAllText(component).single(predicate).let { UiText(this, it) } - } + /** + * Retrieves all UI text elements in a vertically ordered manner. + * + * This method returns a list of lists of `UiText` objects. Each inner list represents a collection of UI text + * elements that are located at the same vertical position on the screen. The outer list represents the entire + * collection of vertically ordered UI text elements. + * + * The UI text elements are ordered horizontally within each vertical position, based on their `x` coordinates. + * + * @return A list of lists of `UiText` objects, representing all UI text elements ordered vertically. + */ + fun getAllVerticallyOrderedUiText(): List> = + getAllTexts().groupBy { it.point.y }.toSortedMap() + .values.map { it.sortedBy { it.point.x } }.toList() + + /** + * Waits for exactly one match in the list of vertically ordered UI text elements. + * That list is originally returned by `getAllVerticallyOrderedUiText` + * + * This method waits until there is exactly one UI text element in the list that matches the specified conditions. + * The conditions can be either a full match of the text or a partial match if the `fullMatch` parameter is set to `false`. + * + * @param message An optional message to display when waiting for a match. + * @param text The text to match against the UI text elements. + * @param fullMatch Flag to indicate whether the match should be a full match or partial match. + * @param timeout The maximum time to wait for the match. + * @return A list of `UiText` objects that match the specified conditions. + */ + fun waitOneMatchInVerticallyOrderedText(message: String? = null, text: String, fullMatch: Boolean = true, timeout: Duration = DEFAULT_FIND_TIMEOUT): List = + waitForOne(message, timeout, + getter = { getAllVerticallyOrderedUiText() }, + checker = { + if (fullMatch) { + text == it.allText() + } + else { + it.allText().contains(text) + } + }) + + fun waitOneMatchInVerticallyOrderedText(text: String, fullMatch: Boolean = true, timeout: Duration = DEFAULT_FIND_TIMEOUT): List = + waitOneMatchInVerticallyOrderedText("Find '${text}'(fullMatch = $fullMatch) in vertically ordered text", text, fullMatch, timeout = timeout) fun present(): Boolean { return data.parentSearchContext.findAll(data.xpath).isNotEmpty() @@ -150,22 +334,14 @@ open class UiComponent(private val data: ComponentData) : Finder, WithKeyboard { return data.parentSearchContext.findAll(data.xpath).isEmpty() } - fun hasText(predicate: (TextData) -> Boolean): Boolean { - return searchService.findAllText(component).any(predicate) + fun hasText(predicate: (UiText) -> Boolean): Boolean { + return getAllTexts().any(predicate) } fun isVisible(): Boolean = component.isVisible() fun isEnabled(): Boolean = component.isEnabled() - fun findAllText(predicate: (TextData) -> Boolean): List { - return searchService.findAllText(component).filter(predicate).map { UiText(this, it) } - } - - fun findAllText(): List { - return searchService.findAllText(component).map { UiText(this, it) } - } - fun hasVisibleComponent(component: UiComponent): Boolean { val components = searchContext.findAll(component.data.xpath) if (components.isEmpty()) return false @@ -217,6 +393,7 @@ open class UiComponent(private val data: ComponentData) : Finder, WithKeyboard { } fun click(button: RemoteMouseButton, count: Int) { + LOG.info("Click with $button $count times at $this") robot.click(component, button, count) } diff --git a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/conditions.kt b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/conditions.kt index 82334c4b61cf..be83d8e2aca8 100644 --- a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/conditions.kt +++ b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/conditions.kt @@ -5,21 +5,20 @@ import com.intellij.driver.sdk.ui.components.UiComponent import com.intellij.driver.sdk.ui.components.button import com.intellij.driver.sdk.waitFor import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds // should infix fun T.should(condition: T.() -> Boolean): T { - return should(timeout = DEFAULT_FIND_TIMEOUT_SECONDS.seconds, condition = condition) + return should(timeout = DEFAULT_FIND_TIMEOUT, condition = condition) } // should not infix fun T.shouldNot(condition: T.() -> Boolean): T { - return should(timeout = DEFAULT_FIND_TIMEOUT_SECONDS.seconds, condition = { !condition() }) + return should(timeout = DEFAULT_FIND_TIMEOUT, condition = { !condition() }) } // should infix fun T.shouldBe(condition: T.() -> Boolean): T { - return should(timeout = DEFAULT_FIND_TIMEOUT_SECONDS.seconds, condition = condition) + return should(timeout = DEFAULT_FIND_TIMEOUT, condition = condition) } fun T.shouldBe(message: String, condition: T.() -> Boolean, timeout: Duration): T { @@ -27,7 +26,7 @@ fun T.shouldBe(message: String, condition: T.() -> Boolean, ti } fun T.shouldBe(message: String, condition: T.() -> Boolean): T { - return should(message = message, timeout = DEFAULT_FIND_TIMEOUT_SECONDS.seconds, condition = condition) + return should(message = message, timeout = DEFAULT_FIND_TIMEOUT, condition = condition) } fun T.shouldBe(condition: T.() -> Boolean, timeout: Duration): T { @@ -36,11 +35,11 @@ fun T.shouldBe(condition: T.() -> Boolean, timeout: Duration): // should infix fun T.shouldHave(condition: T.() -> Boolean): T { - return should(timeout = DEFAULT_FIND_TIMEOUT_SECONDS.seconds, condition = condition) + return should(timeout = DEFAULT_FIND_TIMEOUT, condition = condition) } fun T.shouldHave(message: String, condition: T.() -> Boolean): T { - return should(message = message, timeout = DEFAULT_FIND_TIMEOUT_SECONDS.seconds, condition = condition) + return should(message = message, timeout = DEFAULT_FIND_TIMEOUT, condition = condition) } fun T.shouldHave(condition: T.() -> Boolean, timeout: Duration): T { @@ -51,22 +50,12 @@ fun T.shouldHave(message: String, condition: T.() -> Boolean, return should(message = message, timeout = timeout, condition = condition) } -fun T.should(seconds: Int = DEFAULT_FIND_TIMEOUT_SECONDS, condition: T.() -> Boolean): T { - return should(timeout = seconds.seconds, condition = condition) -} - -fun T.should(message: String, - seconds: Int = DEFAULT_FIND_TIMEOUT_SECONDS, - condition: T.() -> Boolean): T { - return should(message = message, timeout = seconds.seconds, condition = condition) -} - -fun T.should(message: String = "", - timeout: Duration = DEFAULT_FIND_TIMEOUT_SECONDS.seconds, +fun T.should(message: String? = null, + timeout: Duration = DEFAULT_FIND_TIMEOUT, condition: T.() -> Boolean): T { var lastException: Throwable? = null try { - waitFor(timeout, errorMessage = message) { + waitFor(message, timeout) { try { this.condition() } @@ -76,7 +65,7 @@ fun T.should(message: String = "", } } } catch (e: WaitForException){ - throw WaitForException(e.duration, e.errorMessage, lastException) + throw WaitForException(e.timeout, e.errorMessage, lastException) } return this } diff --git a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/keyboard/RemoteKeyboard.kt b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/keyboard/RemoteKeyboard.kt index 08240cdb2b28..9491f654e580 100644 --- a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/keyboard/RemoteKeyboard.kt +++ b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/keyboard/RemoteKeyboard.kt @@ -1,22 +1,56 @@ package com.intellij.driver.sdk.ui.keyboard import com.intellij.driver.sdk.ui.remote.Robot +import com.intellij.openapi.diagnostic.logger import java.awt.event.KeyEvent class RemoteKeyboard(private val robot: Robot) { + companion object { + private val LOG + get() = logger() + } fun key(key: Int) = robot.pressAndReleaseKey(key) - fun enter() = key(KeyEvent.VK_ENTER) - fun escape() = key(KeyEvent.VK_ESCAPE) - fun down() = key(KeyEvent.VK_DOWN) - fun up() = key(KeyEvent.VK_UP) - fun left() = key(KeyEvent.VK_LEFT) - fun right() = key(KeyEvent.VK_RIGHT) - fun backspace() = key(KeyEvent.VK_BACK_SPACE) - fun tab() = key(KeyEvent.VK_TAB) - fun space() = key(KeyEvent.VK_SPACE) + fun enter() { + LOG.info("Pressing enter") + key(KeyEvent.VK_ENTER) + } + fun escape() { + LOG.info("Pressing escape") + key(KeyEvent.VK_ESCAPE) + } + fun down() { + LOG.info("Pressing down") + key(KeyEvent.VK_DOWN) + } + fun up() { + LOG.info("Pressing up") + key(KeyEvent.VK_UP) + } + fun left() { + LOG.info("Pressing left") + key(KeyEvent.VK_LEFT) + } + fun right() { + LOG.info("Pressing right") + key(KeyEvent.VK_RIGHT) + } + fun backspace() { + LOG.info("Pressing backspace") + key(KeyEvent.VK_BACK_SPACE) + } + fun tab() { + LOG.info("Pressing tab") + key(KeyEvent.VK_TAB) + } + fun space() { + LOG.info("Pressing space") + key(KeyEvent.VK_SPACE) + } fun hotKey(vararg keyCodes: Int) { + LOG.info("Pressing hotkeys ${keyCodes.joinToString(",") {"'$it'"}}.") + keyCodes.forEach { robot.pressKey(it) Thread.sleep(100) @@ -28,6 +62,7 @@ class RemoteKeyboard(private val robot: Robot) { } fun enterText(text: String, delayBetweenCharsInMs: Long = 50) { + LOG.info("Entering text '$text'.") text.forEach { robot.type(it) Thread.sleep(delayBetweenCharsInMs) @@ -35,12 +70,16 @@ class RemoteKeyboard(private val robot: Robot) { } fun pressing(key: Int, doWhilePress: RemoteKeyboard.() -> Unit) { + LOG.info("Performing action while pressing $key.") + robot.pressKey(key) this.doWhilePress() robot.releaseKey(key) } fun doublePressing(key: Int, doWhilePress: RemoteKeyboard.() -> Unit) { + LOG.info("Performing action while double pressing $key.") + try { robot.doublePressKeyAndHold(key) this.doWhilePress() diff --git a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/utils.kt b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/utils.kt index d1e58842d0f6..b6ee388aebf6 100644 --- a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/utils.kt +++ b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/ui/utils.kt @@ -28,3 +28,16 @@ interface RectangleRef { fun getY(): Double fun getWidth(): Double } + +fun printableString(toPrint: String): String { + val resultString = toPrint.let { + val maxLength = 600 + if (it.length < maxLength) { + it + } + else { + it.take(maxLength) + "..." + } + } + return resultString +} \ No newline at end of file diff --git a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/waits.kt b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/waits.kt index f922a2d6ab36..9b5d682699a8 100644 --- a/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/waits.kt +++ b/platform/remote-driver/test-sdk/src/com/intellij/driver/sdk/waits.kt @@ -1,54 +1,72 @@ package com.intellij.driver.sdk import com.intellij.driver.sdk.ui.components.UiComponent +import com.intellij.driver.sdk.ui.printableString +import com.intellij.openapi.diagnostic.fileLogger import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -fun waitFor( - duration: Duration = 5.seconds, - interval: Duration = 1.seconds, - errorMessage: String = "", - condition: () -> Boolean -) { - waitFor(duration = duration, interval = interval, errorMessage = errorMessage, getter = condition, checker = { it }) -} +private val LOG get() = fileLogger() fun waitFor( - duration: Duration = 5.seconds, + message: String? = null, + timeout: Duration = 5.seconds, interval: Duration = 1.seconds, - errorMessage: () -> String, - condition: () -> Boolean + errorMessage: (() -> String)? = null, + condition: () -> Boolean, ) { - waitFor(duration = duration, interval = interval, errorMessage = errorMessage, getter = condition, checker = { it }) + waitFor(message = message, + timeout = timeout, + interval = interval, + errorMessage = if (errorMessage == null) { + null + } + else { + run@{ errorMessage.invoke() } + }, + getter = condition, + checker = { it }) } fun waitNotNull( - duration: Duration = 5.seconds, + message: String? = null, + timeout: Duration = 5.seconds, interval: Duration = 1.seconds, - errorMessage: String = "", - getter: () -> T? + errorMessage: ((T?) -> String)? = null, + getter: () -> T?, ): T { - return waitFor(duration = duration, interval = interval, errorMessage = errorMessage, getter = getter, checker = { it != null })!! + return waitFor(message = message, timeout = timeout, + interval = interval, + errorMessage = if (errorMessage == null) { + null + } + else { it -> errorMessage.invoke(it) }, + getter = getter, + checker = { it != null } + )!! } -fun waitFor( - duration: Duration = 5.seconds, - interval: Duration = 1.seconds, - errorMessage: String = "", - getter: () -> T, - checker: (T) -> Boolean -): T { - return waitFor(duration = duration, interval = interval, errorMessage = { errorMessage }, getter = getter, checker = checker) +private fun logAwaitStart(message: String?, timeout: Duration) { + message?.let { LOG.info("Await: '$it' with timeout $timeout") } } +private fun logAwaitFinish(message: String?, result: T) { + message?.let { + LOG.info("Await: '$it' resulted with \n\t${printableString(result.toString())}") + } +} + + fun waitFor( - duration: Duration = 5.seconds, + message: String? = null, + timeout: Duration = 5.seconds, interval: Duration = 1.seconds, - errorMessage: () -> String = { "" }, + errorMessage: ((T) -> String)? = null, getter: () -> T, - checker: (T) -> Boolean + checker: (T) -> Boolean, ): T { - val endTime = System.currentTimeMillis() + duration.inWholeMilliseconds + logAwaitStart(message, timeout) + val endTime = System.currentTimeMillis() + timeout.inWholeMilliseconds var now = System.currentTimeMillis() var result = getter() while (now < endTime && checker(result).not()) { @@ -57,26 +75,87 @@ fun waitFor( now = System.currentTimeMillis() } if (checker(result).not()) { - throw WaitForException(duration, errorMessage() + if (result !is Boolean) " Actual: $result" else "") + throw WaitForException(timeout, + errorMessage = errorMessage?.invoke(result) + ?: ("Failed: $message" + if (result !is Boolean) ". Actual: $result" else "")) } else { + if (result !is Boolean) { + logAwaitFinish(message, result) + } return result } } -fun retryWithFallback( - fallbackAction: () -> T, - mainAction: () -> T, +/** + * Waits until there is exactly one match in getter result abiding checker. + */ +fun waitForOne( + message: String? = null, + timeout: Duration = 5.seconds, + interval: Duration = 1.seconds, + errorMessage: ((List) -> String)? = null, + getter: () -> List, + checker: (T) -> Boolean, ): T { - return try { - mainAction() - } catch (_: Exception) { - fallbackAction() - mainAction() + logAwaitStart(message, timeout) + val endTime = System.currentTimeMillis() + timeout.inWholeMilliseconds + var now = System.currentTimeMillis() + var resultList = getter() + var filteredResultList = resultList.filter { checker(it) } + while (now < endTime && filteredResultList.size != 1) { + Thread.sleep(interval.inWholeMilliseconds) + resultList = getter() + filteredResultList = resultList.filter { checker(it) } + now = System.currentTimeMillis() + } + if (filteredResultList.size != 1) { + throw WaitForException(timeout, + errorMessage = errorMessage?.invoke(resultList) + ?: ("Failed: $message. " + + "\n\tExpected one suitable instance, but got: " + + "\n\tReceived list: ${resultList.joinToString("\n\t")}" + + "\n\tSuitable list: ${filteredResultList.joinToString("\n\t")}")) + } + else { + return filteredResultList.single().also { + logAwaitFinish(message, it) + } } } -fun withRetries(times: Int, onError: () -> Unit = {}, f: () -> T): T { +fun waitForOne( + message: String? = null, + timeout: Duration = 5.seconds, + interval: Duration = 1.seconds, + errorMessage: ((List) -> String)? = null, + getter: () -> List, +): T { + logAwaitStart(message, timeout) + val endTime = System.currentTimeMillis() + timeout.inWholeMilliseconds + var now = System.currentTimeMillis() + var resultList = getter() + while (now < endTime && resultList.size != 1) { + Thread.sleep(interval.inWholeMilliseconds) + resultList = getter() + now = System.currentTimeMillis() + } + if (resultList.size != 1) { + throw WaitForException(timeout, + errorMessage = errorMessage?.invoke(resultList) + ?: ("Failed: $message. " + + "\n\tExpected one suitable instance, but got:" + + "\n\t${resultList.joinToString("\n\t")}")) + } + else { + return resultList.single().also { + logAwaitFinish(message, it) + } + } +} + +fun withRetries(message: String? = null, times: Int, onError: () -> Unit = {}, f: () -> T): T { + message?.let { LOG.info("With $times attempts: $it") } require(times > 0) var lastException: Exception? = null for (i in 0 until times) { @@ -91,10 +170,10 @@ fun withRetries(times: Int, onError: () -> Unit = {}, f: () -> T): T { throw RetryException(lastException!!) } -class WaitForException(val duration: Duration, val errorMessage: String, cause: Throwable? = null) : IllegalStateException("Timeout($duration): $errorMessage", cause) -class RetryException(cause: Exception): RuntimeException(cause) +class WaitForException(val timeout: Duration, val errorMessage: String, cause: Throwable? = null) : IllegalStateException("Timeout($timeout): $errorMessage", cause) +class RetryException(cause: Exception) : RuntimeException(cause) -fun T.wait(duration: Duration): T { - Thread.sleep(duration.inWholeMilliseconds) +fun T.wait(timeout: Duration): T { + Thread.sleep(timeout.inWholeMilliseconds) return this } \ No newline at end of file