Skip to content

Commit

Permalink
Feature/notifications (#71)
Browse files Browse the repository at this point in the history
* example: change default port to 8081

* AutomatorServer: add support for passing resourceName to BySelector

* maestro_test: add some doc comments

* AutomatorServer: add tap2 method to tap on texts

* implement new Selector

* remove implementation of getNativeWidgets (it's TBD)

* AutomatorServer: SelectorQuery: add converter method to BySelector

* AutomatorServer: remove getNativeWidget

* add swipe()

* add native swipes

* fix maestro bootstrap

* AutomatorServer: implement getNotifications()

* maestro_test: implement getNotifications()

* maestro_cli: trim() output from external tools before printing

* maestro_test: add getFirstNotification()

* AutomatorServer: implement tapOnNotification

* add .gitattributes

* move .gitattributes to root dir

* getNotifications: change from POST to GET

* simplify API
* automatically open notification shade on `tapOnNotification()` and `getNotifications()`

* automatically close keyboard after `enterText()``

* maestro_test: add Notification.toString()

* improve example

* fix encoding issues (use UTF-8)

* improve example for notifications

* do openNotifications() on native side

* maestro_test: add CHANGELOG for version 0.2.0

* minor fix for Android View classes

* Maestro.enterText(): make `index` a required named argument

* split enterText() into enterTextByIndex() and enterTextBySelector()

* rename `isRunning()` to `healthCheck()`

* split `openNotifications()` into `openHalfNotificationShade()` and `openFullNotificationShade()`

* add delays

* change native timeout to make tests less flaky

* maestro bootstrap: minor generated files changes

* maestro_cli: set version to 0.2.0
  • Loading branch information
bartekpacia authored Jun 28, 2022
1 parent f49ee7b commit 382a317
Show file tree
Hide file tree
Showing 27 changed files with 1,345 additions and 483 deletions.
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
*.apk filter=lfs diff=lfs merge=lfs -text
*.freezed.dart linguist-generated=true
*.g.dart linguist-generated=true
2 changes: 1 addition & 1 deletion AutomatorServer/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ android {
minSdkVersion 26
targetSdkVersion 31
versionCode 1
versionName "0.1.5"
versionName "0.2.0"

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package pl.leancode.automatorserver

import androidx.test.uiautomator.By
import androidx.test.uiautomator.BySelector
import androidx.test.uiautomator.UiObjectNotFoundException
import androidx.test.uiautomator.UiSelector
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.http4k.core.ContentType
import org.http4k.core.Filter
import org.http4k.core.Method.GET
import org.http4k.core.Method.POST
Expand All @@ -14,6 +18,7 @@ import org.http4k.core.Status.Companion.BAD_REQUEST
import org.http4k.core.Status.Companion.INTERNAL_SERVER_ERROR
import org.http4k.core.Status.Companion.NOT_FOUND
import org.http4k.core.Status.Companion.OK
import org.http4k.filter.ServerFilters
import org.http4k.routing.bind
import org.http4k.routing.routes
import org.http4k.server.Http4kServer
Expand All @@ -23,68 +28,233 @@ import java.util.Timer
import kotlin.concurrent.schedule

@Serializable
data class TapCommand(val index: Int)
data class SwipeCommand(
var startX: Float,
var startY: Float,
var endX: Float,
var endY: Float,
var steps: Int
)

@Serializable
data class TapOnNotificationCommand(val index: Int)

@Serializable
data class EnterTextCommand(val index: Int, val text: String)
data class EnterTextByIndexCommand(val index: Int, val text: String)

const val TextClass = "android.widget.TextView"
const val TextFieldClass = "android.widget.EditText"
const val ButtonClass = "android.widget.Button"
@Serializable
data class EnterTextBySelectorCommand(val selector: SelectorQuery, val text: String)

@Serializable
data class WidgetsQuery(
val fullyQualifiedName: String? = null,
val className: String? = null,
val enabled: Boolean? = null,
val focused: Boolean? = null,
data class SelectorQuery(
val text: String? = null,
val textStartsWith: String? = null,
val textContains: String? = null,
val className: String? = null,
val contentDescription: String? = null,
val contentDescriptionStartsWith: String? = null,
val contentDescriptionContains: String? = null,
val resourceId: String? = null,
val instance: Int? = null,
val enabled: Boolean? = null,
val focused: Boolean? = null,
val pkg: String? = null
) {
fun isEmpty(): Boolean {
return (
fullyQualifiedName == null &&
text == null &&
textStartsWith == null &&
textContains == null &&
className == null &&
clazz() == null &&
contentDescription == null &&
contentDescriptionStartsWith == null &&
contentDescriptionContains == null &&
resourceId == null &&
instance == null &&
enabled == null &&
focused == null &&
text == null &&
textContains == null &&
contentDescription == null
pkg == null
)
}

fun clazz(): String? {
return when (className) {
"Text" -> TextClass
"TextField" -> TextFieldClass
"Button" -> ButtonClass
else -> null
fun toUiSelector(): UiSelector {
var selector = UiSelector()

if (text != null) {
selector = selector.text(text)
}

if (textStartsWith != null) {
selector = selector.textStartsWith(textStartsWith)
}

if (textContains != null) {
selector = selector.textContains(textContains)
}

if (className != null) {
selector = selector.className(className)
}

if (contentDescription != null) {
selector = selector.description(contentDescription)
}

if (contentDescriptionStartsWith != null) {
selector = selector.descriptionStartsWith(contentDescriptionStartsWith)
}

if (contentDescriptionContains != null) {
selector = selector.descriptionContains(contentDescriptionContains)
}

if (resourceId != null) {
selector = selector.resourceId(resourceId)
}

if (instance != null) {
selector = selector.instance(instance)
}

if (enabled != null) {
selector = selector.enabled(enabled)
}

if (focused != null) {
selector = selector.focused(focused)
}

if (pkg != null) {
selector = selector.packageName(pkg)
}

return selector
}

fun toBySelector(): BySelector {
if (isEmpty()) {
throw IllegalStateException("SelectorQuery is empty")
}

var matchedText = false
var matchedTextStartsWith = false
var matchedTextContains = false
var matchedClassName = false
var matchedContentDescription = false
var matchedContentDescriptionStartsWith = false
var matchedContentDescriptionContains = false
var matchedResourceId = false
var matchedEnabled = false
var matchedFocused = false
var matchedPkg = false

var bySelector = if (text != null) {
matchedText = true
By.text(text)
} else if (textStartsWith != null) {
matchedTextStartsWith = true
By.textStartsWith(textStartsWith)
} else if (textContains != null) {
matchedTextContains = true
By.textContains(textContains)
} else if (className != null) {
matchedClassName = true
By.clazz(className)
} else if (contentDescription != null) {
matchedContentDescription = true
By.desc(contentDescription)
} else if (contentDescriptionStartsWith != null) {
matchedContentDescriptionStartsWith = true
By.descStartsWith(contentDescriptionStartsWith)
} else if (contentDescriptionContains != null) {
matchedContentDescriptionContains = true
By.descContains(contentDescriptionContains)
} else if (resourceId != null) {
matchedResourceId = true
By.res(resourceId)
} else if (instance != null) {
throw IllegalArgumentException("instance() argument is not supported for BySelector")
} else if (enabled != null) {
matchedEnabled = true
By.enabled(enabled)
} else if (focused != null) {
matchedFocused = true
By.focused(focused)
} else if (pkg != null) {
matchedPkg = true
By.pkg(pkg)
} else {
throw IllegalArgumentException("SelectorQuery is empty")
}

if (!matchedText && text != null) {
bySelector = By.copy(bySelector).text(text)
}

if (!matchedTextStartsWith && textStartsWith != null) {
bySelector = By.copy(bySelector).textStartsWith(textStartsWith)
}

if (!matchedTextContains && textContains != null) {
bySelector = By.copy(bySelector).textContains(textContains)
}

if (!matchedClassName && className != null) {
bySelector = By.copy(bySelector).clazz(className)
}

if (!matchedContentDescription && contentDescription != null) {
bySelector = By.copy(bySelector).desc(contentDescription)
}

if (!matchedContentDescriptionStartsWith && contentDescriptionStartsWith != null) {
bySelector = By.copy(bySelector).descStartsWith(contentDescriptionStartsWith)
}

if (!matchedContentDescriptionContains && contentDescriptionContains != null) {
bySelector = By.copy(bySelector).descContains(contentDescriptionContains)
}

if (!matchedResourceId && resourceId != null) {
bySelector = By.copy(bySelector).res(resourceId)
}

if (instance != null) {
throw IllegalArgumentException("instance() argument is not supported for BySelector")
}

if (!matchedEnabled && enabled != null) {
bySelector = bySelector.enabled(enabled)
}

if (!matchedFocused && focused != null) {
bySelector = bySelector.focused(focused)
}

if (!matchedPkg && pkg != null) {
bySelector = bySelector.pkg(pkg)
}

return bySelector
}
}

class ServerInstrumentation {
var running = false
var server: Http4kServer? = null
private var server: Http4kServer? = null

fun start() {
server?.stop()
UIAutomatorInstrumentation.instance.configure()
running = true

val router = routes(
"healthCheck" bind GET to {
Logger.i("Health check")
"isRunning" bind GET to {
Response(OK).body("All is good.")
},
"stop" bind POST to {
stop()
Response(OK).body("Server stopped")
Response(OK).body("Server stopped.")
},
"pressBack" bind POST to {
UIAutomatorInstrumentation.instance.pressBack()
Expand All @@ -102,27 +272,45 @@ class ServerInstrumentation {
UIAutomatorInstrumentation.instance.pressDoubleRecentApps()
Response(OK)
},
"openNotifications" bind POST to {
UIAutomatorInstrumentation.instance.openNotifications()
"openHalfNotificationShade" bind POST to {
UIAutomatorInstrumentation.instance.openHalfNotificationShade()
Response(OK)
},
"openFullNotificationShade" bind POST to {
UIAutomatorInstrumentation.instance.openFullNotificationShade()
Response(OK)
},
"getNotifications" bind GET to {
val notifications = UIAutomatorInstrumentation.instance.getNotifications()
Response(OK).body(Json.encodeToString(notifications))
},
"tapOnNotification" bind POST to {
val body = Json.decodeFromString<TapOnNotificationCommand>(it.bodyString())
UIAutomatorInstrumentation.instance.tapOnNotification(body.index)
Response(OK)
},
"tap" bind POST to {
val body = Json.decodeFromString<TapCommand>(it.bodyString())
UIAutomatorInstrumentation.instance.tap(body.index)
val body = Json.decodeFromString<SelectorQuery>(it.bodyString())
UIAutomatorInstrumentation.instance.tap(body)
Response(OK)
},
"enterText" bind POST to {
val body = Json.decodeFromString<EnterTextCommand>(it.bodyString())
"enterTextByIndex" bind POST to {
val body = Json.decodeFromString<EnterTextByIndexCommand>(it.bodyString())
UIAutomatorInstrumentation.instance.enterText(body.index, body.text)
Response(OK)
},
"enterTextBySelector" bind POST to {
val body = Json.decodeFromString<EnterTextBySelectorCommand>(it.bodyString())
UIAutomatorInstrumentation.instance.enterText(body.selector, body.text)
Response(OK)
},
"swipe" bind POST to {
val body = Json.decodeFromString<SwipeCommand>(it.bodyString())
UIAutomatorInstrumentation.instance.swipe(body)
Response(OK)
},
"getNativeWidgets" bind POST to {
val body = Json.decodeFromString<WidgetsQuery>(it.bodyString())
val body = Json.decodeFromString<SelectorQuery>(it.bodyString())
val textFields = UIAutomatorInstrumentation.instance.getNativeWidgets(body)
Response(OK).body(Json.encodeToString(textFields))
},
Expand Down Expand Up @@ -160,12 +348,15 @@ class ServerInstrumentation {
}
)

val port = UIAutomatorInstrumentation.instance.port ?: throw Exception("Could not start server: port is null")
val port = UIAutomatorInstrumentation.instance.port
?: throw Exception("Could not start server: port is null")

Logger.i("Starting server on port $port")

server = router.withFilter(catcher)
server = router
.withFilter(catcher)
.withFilter(printer)
.withFilter(ServerFilters.SetContentType(ContentType.TEXT_PLAIN))
.asServer(Netty(port))
.start()
}
Expand Down
Loading

0 comments on commit 382a317

Please sign in to comment.