com.github.mvysny.kaributesting.v10.Locator.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of karibu-testing-v10 Show documentation
Show all versions of karibu-testing-v10 Show documentation
Karibu Testing, support for browserless Vaadin testing in Kotlin
@file:Suppress("FunctionName")
package com.github.mvysny.kaributesting.v10
import com.github.mvysny.kaributools.*
import com.vaadin.flow.component.Component
import com.vaadin.flow.component.HasStyle
import com.vaadin.flow.component.HasText
import com.vaadin.flow.component.HasValue
import com.vaadin.flow.component.UI
import com.vaadin.flow.component.dialog.Dialog
import com.vaadin.flow.component.icon.VaadinIcon
import com.vaadin.flow.router.InternalServerError
import java.io.PrintStream
import java.util.function.Predicate
/**
* A criterion for matching components. The component must match all of non-null fields.
*
* You can add more properties, simply by creating a write-only property which will register a new [predicates] on write. See
* [Adding support for custom search criteria](https://github.com/mvysny/karibu-testing/tree/master/karibu-testing-v10#adding-support-for-custom-search-criteria)
* for more details.
* @param T the class of the component we are searching for.
* @property clazz the class of the component we are searching for.
* @property id the required [Component.getId]; if null, no particular id is matched.
* @property label the required [Component.label]; if null, no particular label is matched.
* @property caption the required [Component.caption]; if null, no particular caption is matched. Deprecated: use [text] for Buttons, [label] for everything else.
* @property placeholder the required [Component.placeholder]; if null, no particular placeholder is matched.
* @property text the [HasText.getText]; use for button's "caption"
* @property count expected count of matching components, defaults to `0..Int.MAX_VALUE`
* @property value expected [com.vaadin.flow.component.HasValue.getValue]; if `null`, no particular value is matched.
* @property classes if not null, the component must match all of these class names. Space-separated.
* @property withoutClasses if not null, the component must NOT match any of these class names. Space-separated.
* @property themes if not null, the component must have all theme names defined. Space-separated
* @property withoutThemes if not null, the component must NOT have any of the theme names defined. Space-separated
* @property icon if not null, the component must have given [_iconName].
* @property enabled if not null, the component's [Component.isEnabled] must match this value.
* @property predicates the predicates the component needs to match, not null. May be empty - in such case it is ignored. By default, empty.
*/
public class SearchSpec(
public val clazz: Class,
public var id: String? = null,
public var label: String? = null,
@Deprecated("Use 'text' for Buttons, 'label' for everything else")
public var caption: String? = null,
public var placeholder: String? = null,
public var text: String? = null,
public var count: IntRange = 0..Int.MAX_VALUE,
public var value: Any? = null,
public var classes: String? = null,
public var withoutClasses: String? = null,
public var themes: String? = null,
public var withoutThemes: String? = null,
public var icon: IconName? = null,
public var enabled: Boolean? = null,
public var predicates: MutableList> = mutableListOf()
) {
/**
* Provides a nice summary of all rules set to this spec.
*/
override fun toString(): String {
val list = mutableListOf(clazz.simpleName.ifBlank { clazz.name })
if (id != null) list.add("id='$id'")
if (label != null) list.add("label='$label'")
@Suppress("DEPRECATION")
if (caption != null) list.add("caption='$caption'")
if (placeholder != null) list.add("placeholder='$placeholder'")
if (text != null) list.add("text='$text'")
if (!classes.isNullOrBlank()) list.add("classes='$classes'")
if (!withoutClasses.isNullOrBlank()) list.add("withoutClasses='$withoutClasses'")
if (!themes.isNullOrBlank()) list.add("themes='$themes'")
if (!withoutThemes.isNullOrBlank()) list.add("withoutThemes='$withoutThemes'")
if (icon != null) list.add("icon='$icon'")
if (value != null) list.add("value=$value")
if (enabled != null) list.add(if (enabled!!) "enabled" else "disabled")
if (count != (0..Int.MAX_VALUE) && count != 1..1) list.add("count=$count")
list.addAll(predicates.map { it.toString() })
return list.joinToString(" and ")
}
/**
* Returns a predicate which matches components based on this spec. All rules are matched except the [count] rule. The
* rules are matched against given component only (not against its children).
*/
public fun toPredicate(): (Component) -> Boolean {
val p = mutableListOf<(Component)->Boolean>()
p.add { component -> clazz.isInstance(component)}
if (id != null) p.add { component -> component.id_ == id }
if (label != null) p.add { component -> testingLifecycleHook.getLabel(component) == label }
@Suppress("DEPRECATION")
if (caption != null) p.add { component -> component.caption == caption }
if (placeholder != null) p.add { component -> component.placeholder == placeholder }
if (!classes.isNullOrBlank()) p.add { component -> component.hasAllClasses(classes!!) }
if (!withoutClasses.isNullOrBlank()) p.add { component -> component.doesntHaveAnyClasses(withoutClasses!!) }
if (!themes.isNullOrBlank()) p.add { component -> component.hasAllThemes(themes!!) }
if (!withoutThemes.isNullOrBlank()) p.add { component -> component.notContainsThemes(withoutThemes!!) }
if (text != null) p.add { component -> component._text == text }
if (value != null) p.add { component -> (component as? HasValue<*, *>)?.value == value }
if (icon != null) p.add { component -> component._iconName == icon }
if (enabled != null) p.add { component -> component.isEnabled == enabled }
@Suppress("UNCHECKED_CAST")
p.addAll(predicates.map { predicate -> { component: Component -> clazz.isInstance(component) && predicate.test(component as T) } })
return p.and()
}
/**
* Makes sure that [_iconName] is of given [collection] and matches the [iconName].
*/
public fun iconIs(collection: String, iconName: String) {
this.icon = IconName(collection, iconName)
}
/**
* Makes sure that [_iconName] is given [vaadinIcon].
*/
public fun iconIs(vaadinIcon: VaadinIcon) {
this.icon = IconName.of(vaadinIcon)
}
}
/**
* Return a list containing only strings that are not null nor [String.isNotBlank].
*/
public fun Iterable.filterNotBlank(): List = filterNotNull().filter { it.isNotBlank() }
private fun Component.hasAllClasses(classes: String): Boolean {
@Suppress("USELESS_IS_CHECK") // still needed for earlier Vaadin
if (this !is HasStyle) return false
return classes.split(' ').filterNotBlank().all { classNames.contains(it) }
}
private fun Component.doesntHaveAnyClasses(classes: String): Boolean {
@Suppress("USELESS_IS_CHECK") // still needed for earlier Vaadin
if (this !is HasStyle) return true
return classes.split(' ').filterNotBlank().all { !classNames.contains(it) }
}
private fun Component.hasAllThemes(themes: String): Boolean {
return themes.split(' ').filterNotBlank().all { element.themeList.contains(it) }
}
private fun Component.notContainsThemes(themes: String): Boolean {
return themes.split(' ').filterNotBlank().all { !element.themeList.contains(it) }
}
/**
* Finds a VISIBLE component of given type which matches given [block]. This component and all of its descendants are searched.
* @param block the search specification
* @return the only matching component, never null.
* @throws IllegalArgumentException if no component matched, or if more than one component matches.
*/
public inline fun Component._get(noinline block: SearchSpec.()->Unit = {}): T = this._get(T::class.java, block)
/**
* Finds a VISIBLE component of given [clazz] which matches given [block]. This component and all of its descendants are searched.
* @param clazz the component must be of this class.
* @param block the search specification
* @return the only matching component, never null.
* @throws IllegalArgumentException if no component matched, or if more than one component matches.
*/
public fun Component._get(clazz: Class, block: SearchSpec.()->Unit = {}): T {
val result: List = _find(clazz) {
count = 1..1
block()
check(count == 1..1) { "You're calling _get which is supposed to return exactly 1 component, yet you tried to specify the count of $count" }
}
return clazz.cast(result.single())
}
/**
* Finds a VISIBLE component in the current UI of given type which matches given [block]. The [currentUI] and all of its descendants are searched.
* @return the only matching component, never null.
* @throws IllegalArgumentException if no component matched, or if more than one component matches.
*/
public inline fun _get(noinline block: SearchSpec.()->Unit = {}): T =
_get(T::class.java, block)
/**
* Finds a VISIBLE component in the current UI of given [clazz] which matches given [block]. The [currentUI] and all of its descendants are searched.
* @param clazz the component must be of this class.
* @param block the search specification
* @return the only matching component, never null.
* @throws IllegalArgumentException if no component matched, or if more than one component matches.
*/
public fun _get(clazz: Class, block: SearchSpec.()->Unit = {}): T = currentUI._get(clazz, block)
/**
* Finds a list of VISIBLE components of given [clazz] which matches [block]. This component and all of its descendants are searched.
* @return the list of matching components, may be empty.
*/
public fun Component._find(clazz: Class, block: SearchSpec.()->Unit = {}): List {
val spec: SearchSpec = SearchSpec(clazz)
spec.block()
val result: List = find(spec.toPredicate())
if (result.size !in spec.count) {
val loc: String = currentPath ?: "?"
var message: String = when {
result.isEmpty() -> "/$loc: No visible ${clazz.simpleName}"
result.size < spec.count.first -> "/$loc: Too few (${result.size}) visible ${clazz.simpleName}s"
else -> "/$loc: Too many visible ${clazz.simpleName}s (${result.size})"
}
message = "$message in ${toPrettyString()} matching $spec: [${result.joinToString { it.toPrettyString() }}]. Component tree:\n${toPrettyTree()}"
// if there's a PolymerTemplate, warn that Karibu-Testing can't really locate components in there:
// https://github.com/mvysny/karibu-testing/tree/master/karibu-testing-v10#polymer-templates
// fixes https://github.com/mvysny/karibu-testing/issues/35
val hasPolymerTemplates: Boolean = _walkAll().any { it.isTemplate }
if (hasPolymerTemplates) {
message = "$message\nWarning: Karibu-Testing is not able to look up components from inside of PolymerTemplate/LitTemplate. Please see https://github.com/mvysny/karibu-testing/tree/master/karibu-testing-v10#polymer-templates--lit-templates for more details."
}
// find() used to fail with IllegalArgumentException which makes sense for a general-purpose utility method. However,
// since find() is used in tests overwhelmingly, not finding the correct set of components is generally treated as an assertion error.
throw AssertionError(message)
}
return result.filterIsInstance(clazz)
}
/**
* Finds a list of VISIBLE components of given type which matches [block]. This component and all of its descendants are searched.
* @return the list of matching components, may be empty.
*/
public inline fun Component._find(noinline block: SearchSpec.()->Unit = {}): List = this._find(T::class.java, block)
/**
* Finds a list of VISIBLE components in the current UI of given type which matches given [block]. The [UI.getCurrent] and all of its descendants are searched.
* @param block the search specification
* @return the list of matching components, may be empty.
*/
public inline fun _find(noinline block: SearchSpec.()->Unit = {}): List =
_find(T::class.java, block)
/**
* Finds a list of VISIBLE components of given [clazz] which matches [block]. The [UI.getCurrent] and all of its descendants are searched.
* @return the list of matching components, may be empty.
*/
public fun _find(clazz: Class, block: SearchSpec.()->Unit = {}): List =
currentUI._find(clazz, block)
private fun Component.find(predicate: (Component)->Boolean): List {
testingLifecycleHook.awaitBeforeLookup()
val descendants: List = _walkAll().toList()
testingLifecycleHook.awaitAfterLookup()
val error: InternalServerError? = descendants.filterIsInstance().firstOrNull()
if (error != null) {
throw AssertionError("An internal server error occurred; please check log for the actual stack-trace. Error text: ${error.errorMessage}\n${currentUI.toPrettyTree()}")
}
return descendants.filter { it.isEffectivelyVisible() && predicate(it) }
}
/**
* `AND`s all predicates.
*/
private fun Iterable<(T) -> Boolean>.and(): (T) -> Boolean =
{ component -> all { it(component) } }
/**
* Walks the component child/descendant tree, depth-first: first the component, then its descendants,
* then its next sibling. Uses [TestingLifecycleHook.getAllChildren] to get the children.
*
* Returns [this] as the first item.
*
* This is a low-level API which doesn't run the [TestingLifecycleHook] lifecycle methods.
* Please consider using [find] instead: `component.find()`
*/
public fun Component._walkAll(): Iterable = Iterable {
DepthFirstTreeIterator(this) { component: Component -> testingLifecycleHook.getAllChildren(component) }
}
/**
* Expects that there are no VISIBLE components of given type which matches [block]. This component and all of its descendants are searched.
* @throws IllegalArgumentException if one or more components matched.
*/
public inline fun Component._expectNone(noinline block: SearchSpec.()->Unit = {}) {
this._expectNone(T::class.java, block)
}
/**
* Expects that there are no VISIBLE components of given [clazz] which matches [block]. This component and all of its descendants are searched.
* @throws IllegalArgumentException if one or more components matched.
*/
public fun Component._expectNone(clazz: Class, block: SearchSpec.()->Unit = {}) {
val result: List = _find(clazz) {
count = 0..0
block()
check(count == 0..0) { "You're calling _expectNone which expects 0 component, yet you tried to specify the count of $count" }
}
check(result.isEmpty()) // safety check that _find works as expected
}
/**
* Expects that there are no dialogs shown.
*/
public fun _expectNoDialogs() {
_expectNone