From c4fa7c1fc2241cd0a0e2f8ce161c12a8175209e8 Mon Sep 17 00:00:00 2001 From: MykytaPimonovTD Date: Thu, 17 Oct 2024 12:23:16 +0300 Subject: [PATCH 01/14] Create `testutil-client` module. --- build.gradle.kts | 9 +---- client/build.gradle.kts | 1 + .../client/e2e/PersonalInteractionIgTest.kt | 1 + desktop/build.gradle.kts | 6 +++ .../io/spine/internal/dependency/Pingh.kt | 5 ++- settings.gradle.kts | 1 + testutil-client/build.gradle.kts | 38 +++++++++++++++++++ .../pingh/testing/client}/IntegrationTest.kt | 5 +-- .../client}/MemoizingNotificationSender.kt | 2 +- 9 files changed, 55 insertions(+), 13 deletions(-) create mode 100644 testutil-client/build.gradle.kts rename {client/src/test/kotlin/io/spine/examples/pingh/client/e2e => testutil-client/src/main/kotlin/io/spine/examples/pingh/testing/client}/IntegrationTest.kt (96%) rename {client/src/test/kotlin/io/spine/examples/pingh/client/e2e/given => testutil-client/src/main/kotlin/io/spine/examples/pingh/testing/client}/MemoizingNotificationSender.kt (97%) diff --git a/build.gradle.kts b/build.gradle.kts index 581b817d..fc608de5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -70,14 +70,7 @@ subprojects { /** * The set of names of modules that required for building the `desktop` standalone project. */ -val modulesRequiredForDesktop = setOf( - "clock", - "github", - "sessions", - "mentions", - "server", - "client" -) +val modulesRequiredForDesktop = project.subprojects.map { it.name }.toSet() /** * Publishes modules required for building the `desktop` standalone project diff --git a/client/build.gradle.kts b/client/build.gradle.kts index 09f6d644..2307717c 100644 --- a/client/build.gradle.kts +++ b/client/build.gradle.kts @@ -53,6 +53,7 @@ dependencies { implementation(Grpc.inprocess) implementation(KotlinX.Coroutines.core) + testImplementation(project(":testutil-client")) testImplementation(project(":testutil-mentions")) testImplementation(project(":testutil-sessions")) testImplementation(project(":clock")) diff --git a/client/src/test/kotlin/io/spine/examples/pingh/client/e2e/PersonalInteractionIgTest.kt b/client/src/test/kotlin/io/spine/examples/pingh/client/e2e/PersonalInteractionIgTest.kt index d380428f..3d1f3e04 100644 --- a/client/src/test/kotlin/io/spine/examples/pingh/client/e2e/PersonalInteractionIgTest.kt +++ b/client/src/test/kotlin/io/spine/examples/pingh/client/e2e/PersonalInteractionIgTest.kt @@ -41,6 +41,7 @@ import io.spine.examples.pingh.github.of import io.spine.examples.pingh.mentions.MentionId import io.spine.examples.pingh.mentions.MentionStatus import io.spine.examples.pingh.mentions.MentionView +import io.spine.examples.pingh.testing.client.IntegrationTest import io.spine.protobuf.Durations2.hours import io.spine.protobuf.Durations2.milliseconds import java.lang.Thread.sleep diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 3a78d120..423b3757 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -34,6 +34,7 @@ import io.spine.internal.dependency.Pingh import io.spine.internal.gradle.AppVersion import io.spine.internal.gradle.allowBackgroundExecution import io.spine.internal.gradle.extractSemanticVersion +import org.jetbrains.compose.ExperimentalComposeLibrary import org.jetbrains.compose.desktop.application.dsl.TargetFormat plugins { @@ -95,6 +96,11 @@ dependencies { implementation(Coil.compose) implementation(Ktor.Client.android) implementation(Pingh.client) + + testImplementation(Pingh.testutilClient) + testImplementation(kotlin("test")) + @OptIn(ExperimentalComposeLibrary::class) + testImplementation(compose.uiTest) } compose.desktop { diff --git a/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Pingh.kt b/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Pingh.kt index 7a4ce601..594597ac 100644 --- a/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Pingh.kt +++ b/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Pingh.kt @@ -29,5 +29,8 @@ package io.spine.internal.dependency // https://github.com/spine-examples/Pingh public object Pingh { private const val version = "1.0.0-SNAPSHOT.7" - public const val client: String = "io.spine.examples.pingh:client:$version" + private const val group = "io.spine.examples.pingh" + + public const val client: String = "$group:client:$version" + public const val testutilClient: String = "$group:testutil-client:$version" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 1696cbc6..39386d44 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,6 +32,7 @@ include( "mentions", "testutil-mentions", "testutil-sessions", + "testutil-client", "server", "client" ) diff --git a/testutil-client/build.gradle.kts b/testutil-client/build.gradle.kts new file mode 100644 index 00000000..ebe1cf2d --- /dev/null +++ b/testutil-client/build.gradle.kts @@ -0,0 +1,38 @@ +/* + * Copyright 2024, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import io.spine.internal.dependency.JUnit +import io.spine.internal.dependency.Spine + +dependencies { + implementation(project(":client")) + implementation(project(":testutil-mentions")) + implementation(project(":testutil-sessions")) + implementation(Spine.server) + implementation(JUnit.api) + implementation(Spine.GCloud.datastore) + implementation(Spine.GCloud.testutil) +} diff --git a/client/src/test/kotlin/io/spine/examples/pingh/client/e2e/IntegrationTest.kt b/testutil-client/src/main/kotlin/io/spine/examples/pingh/testing/client/IntegrationTest.kt similarity index 96% rename from client/src/test/kotlin/io/spine/examples/pingh/client/e2e/IntegrationTest.kt rename to testutil-client/src/main/kotlin/io/spine/examples/pingh/testing/client/IntegrationTest.kt index 22a46070..8d8b9b61 100644 --- a/client/src/test/kotlin/io/spine/examples/pingh/client/e2e/IntegrationTest.kt +++ b/testutil-client/src/main/kotlin/io/spine/examples/pingh/testing/client/IntegrationTest.kt @@ -24,12 +24,11 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -package io.spine.examples.pingh.client.e2e +package io.spine.examples.pingh.testing.client import io.spine.environment.Tests import io.spine.examples.pingh.client.VerifyLogin import io.spine.examples.pingh.client.PinghApplication -import io.spine.examples.pingh.client.e2e.given.MemoizingNotificationSender import io.spine.examples.pingh.mentions.newMentionsContext import io.spine.examples.pingh.server.datastore.DatastoreStorageFactory import io.spine.examples.pingh.sessions.newSessionsContext @@ -48,7 +47,7 @@ import org.junit.jupiter.api.BeforeEach * * Also provides a [PinghApplication] for interacting with the `Server`. */ -internal abstract class IntegrationTest { +public abstract class IntegrationTest { internal companion object { diff --git a/client/src/test/kotlin/io/spine/examples/pingh/client/e2e/given/MemoizingNotificationSender.kt b/testutil-client/src/main/kotlin/io/spine/examples/pingh/testing/client/MemoizingNotificationSender.kt similarity index 97% rename from client/src/test/kotlin/io/spine/examples/pingh/client/e2e/given/MemoizingNotificationSender.kt rename to testutil-client/src/main/kotlin/io/spine/examples/pingh/testing/client/MemoizingNotificationSender.kt index b5fe40ed..ae336abd 100644 --- a/client/src/test/kotlin/io/spine/examples/pingh/client/e2e/given/MemoizingNotificationSender.kt +++ b/testutil-client/src/main/kotlin/io/spine/examples/pingh/testing/client/MemoizingNotificationSender.kt @@ -24,7 +24,7 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -package io.spine.examples.pingh.client.e2e.given +package io.spine.examples.pingh.testing.client import io.spine.examples.pingh.client.NotificationSender From e3c264374d72357f6272859321a8a27cb432b6cb Mon Sep 17 00:00:00 2001 From: MykytaPimonovTD Date: Thu, 17 Oct 2024 12:28:48 +0300 Subject: [PATCH 02/14] Update versions. --- .../src/main/kotlin/io/spine/internal/dependency/Pingh.kt | 2 +- version.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Pingh.kt b/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Pingh.kt index 594597ac..5cf0e05e 100644 --- a/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Pingh.kt +++ b/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Pingh.kt @@ -28,7 +28,7 @@ package io.spine.internal.dependency // https://github.com/spine-examples/Pingh public object Pingh { - private const val version = "1.0.0-SNAPSHOT.7" + private const val version = "1.0.0-SNAPSHOT.8" private const val group = "io.spine.examples.pingh" public const val client: String = "$group:client:$version" diff --git a/version.gradle.kts b/version.gradle.kts index 46333352..854c5f4f 100644 --- a/version.gradle.kts +++ b/version.gradle.kts @@ -27,4 +27,4 @@ /** * The version of the `Pingh` to publish. */ -val pinghVersion: String by extra("1.0.0-SNAPSHOT.7") +val pinghVersion: String by extra("1.0.0-SNAPSHOT.8") From cb7e01ab12e0e48a727ed77e62e22017ebb64b8f Mon Sep 17 00:00:00 2001 From: MykytaPimonovTD Date: Fri, 18 Oct 2024 14:17:24 +0300 Subject: [PATCH 03/14] Create first UI test. --- desktop/build.gradle.kts | 1 + .../io/spine/examples/pingh/desktop/Login.kt | 7 +- .../main/resources/config/server.properties | 2 +- .../examples/pingh/desktop/LoginPageUITest.kt | 65 +++++++++++++++++++ .../examples/pingh/desktop/given/UITestEnv.kt | 63 ++++++++++++++++++ 5 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 desktop/src/test/kotlin/io/spine/examples/pingh/desktop/LoginPageUITest.kt create mode 100644 desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/UITestEnv.kt diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 423b3757..3cff696a 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -91,6 +91,7 @@ dependencies { implementation(Compose.Runtime.lib) implementation(compose.desktop.currentOs) implementation(Material3.Desktop.lib) + implementation(Guava.lib) implementation(Coil.lib) implementation(Coil.networkKtor) implementation(Coil.compose) diff --git a/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/Login.kt b/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/Login.kt index 146d4d5a..f167fcd3 100644 --- a/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/Login.kt +++ b/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/Login.kt @@ -70,6 +70,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -236,7 +237,8 @@ private fun UsernameInput( }, modifier = Modifier .width(180.dp) - .height(52.dp), + .height(52.dp) + .testTag("username-input"), textStyle = MaterialTheme.typography.bodyLarge.copy( color = MaterialTheme.colorScheme.onSecondary ), @@ -371,7 +373,8 @@ private fun LoginButton( onClick = onClick, modifier = Modifier .width(180.dp) - .height(40.dp), + .height(40.dp) + .testTag("login-button"), enabled = enabled, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, diff --git a/desktop/src/main/resources/config/server.properties b/desktop/src/main/resources/config/server.properties index 48bae8b9..06bc1927 100644 --- a/desktop/src/main/resources/config/server.properties +++ b/desktop/src/main/resources/config/server.properties @@ -25,4 +25,4 @@ # server.address=localhost -server.port=50051 +server.port=4242 diff --git a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/LoginPageUITest.kt b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/LoginPageUITest.kt new file mode 100644 index 00000000..e995b0e2 --- /dev/null +++ b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/LoginPageUITest.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2024, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.examples.pingh.desktop + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.runComposeUiTest +import io.spine.examples.pingh.desktop.given.delay +import io.spine.examples.pingh.desktop.given.runApp +import io.spine.examples.pingh.testing.client.IntegrationTest +import kotlin.test.Test +import org.junit.jupiter.api.DisplayName + +@DisplayName("Login page should") +internal class LoginPageUITest : IntegrationTest() { + + private val SemanticsNodeInteractionsProvider.loginButton + get() = onNodeWithTag("login-button") + + private val SemanticsNodeInteractionsProvider.usernameInput + get() = onNodeWithTag("username-input") + + @Test + @OptIn(ExperimentalTestApi::class) + internal fun `have login button enabled only when a valid username is entered`() = + runComposeUiTest { + runApp() + loginButton.assertIsNotEnabled() + usernameInput.performTextInput("()+$") + loginButton.assertIsNotEnabled() + usernameInput.performTextClearance() + usernameInput.performTextInput("MykytaPimonovTD") + delay() + loginButton.assertIsEnabled() + } +} diff --git a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/UITestEnv.kt b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/UITestEnv.kt new file mode 100644 index 00000000..be953cee --- /dev/null +++ b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/UITestEnv.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2024, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.examples.pingh.desktop.given + +import androidx.compose.runtime.remember +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import com.google.common.util.concurrent.Uninterruptibles.sleepUninterruptibly +import io.spine.examples.pingh.desktop.AppState +import io.spine.examples.pingh.desktop.Theme +import io.spine.examples.pingh.desktop.Window +import io.spine.examples.pingh.desktop.retrieveSystemSettings +import java.lang.Thread.sleep +import java.util.concurrent.TimeUnit + +/** + * Launches the Pingh application for testing. + */ +@OptIn(ExperimentalTestApi::class) +internal fun ComposeUiTest.runApp() { + setContent { + Theme { + val settings = retrieveSystemSettings() + val state = remember { AppState(settings) } + Window(state.window, state.app) + } + } +} + +/** + * Causes the currently executing thread to sleep for the specified number of milliseconds + * + * Compose uses coroutines to update UI elements, which means that updates + * may not occur immediately after a state change. Therefore, it is advisable to introduce + * a brief delay before checking for state updates. + */ +internal fun delay(millis: Long = 100) { + sleepUninterruptibly(millis, TimeUnit.MILLISECONDS) +} From 4a99be7a6f5f9d3439c3aaf3c541dc17b4764415 Mon Sep 17 00:00:00 2001 From: MykytaPimonovTD Date: Fri, 18 Oct 2024 16:11:28 +0300 Subject: [PATCH 04/14] Create `TestClient`. --- .../examples/pingh/client/DesktopClient.kt | 16 ++- .../spine/examples/pingh/client/TestClient.kt | 133 ++++++++++++++++++ 2 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 client/src/main/kotlin/io/spine/examples/pingh/client/TestClient.kt diff --git a/client/src/main/kotlin/io/spine/examples/pingh/client/DesktopClient.kt b/client/src/main/kotlin/io/spine/examples/pingh/client/DesktopClient.kt index 8b1b6a6b..eb76e7d4 100644 --- a/client/src/main/kotlin/io/spine/examples/pingh/client/DesktopClient.kt +++ b/client/src/main/kotlin/io/spine/examples/pingh/client/DesktopClient.kt @@ -136,14 +136,16 @@ internal class DesktopClient( */ internal fun observeEvent( type: KClass, - filter: EventFilter, + filter: EventFilter? = null, onEmit: (event: E) -> Unit ): Subscription = - clientRequest() - .subscribeToEvent(type.java) - .where(filter) - .observe(onEmit) - .post() + with(clientRequest().subscribeToEvent(type.java)) { + if (filter != null) { + where(filter) + } + observe(onEmit) + post() + } /** * Subscribes to the event of the provided type and cancels itself after @@ -193,7 +195,7 @@ internal class DesktopClient( * * @param subscription The subscription to be canceled. */ - private fun cancel(subscription: Subscription) { + internal fun cancel(subscription: Subscription) { client.subscriptions() .cancel(subscription) } diff --git a/client/src/main/kotlin/io/spine/examples/pingh/client/TestClient.kt b/client/src/main/kotlin/io/spine/examples/pingh/client/TestClient.kt new file mode 100644 index 00000000..1009e319 --- /dev/null +++ b/client/src/main/kotlin/io/spine/examples/pingh/client/TestClient.kt @@ -0,0 +1,133 @@ +/* + * Copyright 2024, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.examples.pingh.client + +import com.google.common.annotations.VisibleForTesting +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder +import io.spine.base.EventMessage +import io.spine.core.UserId +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import kotlin.reflect.KClass + +/** + * A client for interacting with the Pingh server for testing. + * + * This client provides the ability to subscribe to events and wait for their emission. + * + * @param address The address of the Pingh server. + * @param port The port on which the Pingh server is running. + */ +@VisibleForTesting +public class TestClient(address: String, port: Int) { + public companion object { + /** + * The default amount of seconds to wait + * when [closing][ManagedChannel.shutdown] the channel. + */ + private const val defaultShutdownTimeout = 5L + } + + /** + * Channel for the communication with the Pingh server. + */ + private val channel = ManagedChannelBuilder + .forAddress(address, port) + .usePlaintext() + .build() + + /** + * Enables interaction with the Pingh server. + */ + private val client = DesktopClient( + channel, + UserId.newBuilder() + .setValue("test-client") + .build() + ) + + /** + * Starts observing the emission of the passed event. + * + * Subscribes to an observable event. When this event is emitted, + * the [future][CompletableFuture] is considered complete. + * + * It is crucial to begin observing an event before sending a command + * that triggers its emission. Otherwise, there may be a wait for an event + * that has already been emitted. + * + * @param E The type of the observable event. + * + * @param event The class of the type of the observable event. + */ + public fun observeEvent(event: KClass): Observer { + val future = CompletableFuture() + val subscription = client.observeEvent(event) { + future.complete(Unit) + } + return Observer(future) { client.cancel(subscription) } + } + + /** + * Closes the client. + */ + public fun close() { + client.close() + channel.shutdown() + .awaitTermination(defaultShutdownTimeout, TimeUnit.SECONDS) + } +} + +/** + * An observer for event emission. + * + * Contains a `CompletableFuture` that will be marked as completed + * when the event is emitted. + */ +@VisibleForTesting +public class Observer internal constructor( + private val future: CompletableFuture, + private val cancel: () -> Unit +) { + /** + * Waiting for the event to be emitted. + * + * If no emission occurs within the specified time, + * a [TimeoutException] will be thrown. + * + * @param millis The wait time for emission, specified in milliseconds. + */ + public fun waitUntilDone(millis: Long = 2000) { + try { + future.get(millis, TimeUnit.MILLISECONDS) + } finally { + cancel() + } + } +} From b1d34bb684804f8840c53519cb1689ac7fa869f1 Mon Sep 17 00:00:00 2001 From: MykytaPimonovTD Date: Fri, 18 Oct 2024 18:39:04 +0300 Subject: [PATCH 05/14] Test login page. --- .../spine/examples/pingh/desktop/AppState.kt | 42 ++++++------- .../io/spine/examples/pingh/desktop/Login.kt | 4 +- ...{LoginPageUITest.kt => LoginPageUiTest.kt} | 63 ++++++++++++++++++- .../given/{UITestEnv.kt => UiTestEnv.kt} | 14 ++++- 4 files changed, 97 insertions(+), 26 deletions(-) rename desktop/src/test/kotlin/io/spine/examples/pingh/desktop/{LoginPageUITest.kt => LoginPageUiTest.kt} (56%) rename desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/{UITestEnv.kt => UiTestEnv.kt} (85%) diff --git a/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/AppState.kt b/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/AppState.kt index ba11bb56..5d1d4c85 100644 --- a/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/AppState.kt +++ b/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/AppState.kt @@ -63,33 +63,13 @@ internal class AppState(settings: SystemSettings) { init { val notificationSender = TrayNotificationSender(composeTray) { !window.isShown } - val properties = loadProperties() + val properties = loadServerProperties() app = PinghApplication.builder() .withAddress(properties.getProperty("server.address")) .withPort(properties.getProperty("server.port").toInt()) .with(notificationSender) .build() } - - /** - * Loads server properties from resource folder. - */ - private fun loadProperties(): Properties { - val properties = Properties() - val path = "/config/server.properties" - javaClass.getResourceAsStream(path).use { - properties.load(it) - } - check(properties.containsKey("server.address")) { - "The Pingh server address must be provided in the configuration file " + - "located at \"resource$path\"." - } - check(properties.containsKey("server.port")) { - "The Pingh server port must be provided in the configuration file " + - "located at \"resource$path\"." - } - return properties - } } /** @@ -119,3 +99,23 @@ private class TrayNotificationSender( } } } + +/** + * Loads server properties from resource folder. + */ +internal fun loadServerProperties(): Properties { + val properties = Properties() + val path = "/config/server.properties" + AppState::class.java.getResourceAsStream(path).use { + properties.load(it) + } + check(properties.containsKey("server.address")) { + "The Pingh server address must be provided in the configuration file " + + "located at \"resource$path\"." + } + check(properties.containsKey("server.port")) { + "The Pingh server port must be provided in the configuration file " + + "located at \"resource$path\"." + } + return properties +} diff --git a/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/Login.kt b/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/Login.kt index f167fcd3..21cac06f 100644 --- a/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/Login.kt +++ b/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/Login.kt @@ -621,7 +621,8 @@ private fun SubmitButton( ) { Button( onClick = onClick, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize() + .testTag("submit-button"), enabled = enabled, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, @@ -660,6 +661,7 @@ private fun NoResponseErrorMessage(flow: VerifyLogin) { modifier = Modifier .width(180.dp) .offset(y = 40.dp) + .testTag("no-response-message") ) } diff --git a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/LoginPageUITest.kt b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/LoginPageUiTest.kt similarity index 56% rename from desktop/src/test/kotlin/io/spine/examples/pingh/desktop/LoginPageUITest.kt rename to desktop/src/test/kotlin/io/spine/examples/pingh/desktop/LoginPageUiTest.kt index e995b0e2..4193127c 100644 --- a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/LoginPageUITest.kt +++ b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/LoginPageUiTest.kt @@ -26,22 +26,39 @@ package io.spine.examples.pingh.desktop +import androidx.compose.ui.test.ComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.runComposeUiTest +import io.spine.examples.pingh.desktop.given.createTestClient import io.spine.examples.pingh.desktop.given.delay import io.spine.examples.pingh.desktop.given.runApp +import io.spine.examples.pingh.sessions.event.UserCodeReceived import io.spine.examples.pingh.testing.client.IntegrationTest import kotlin.test.Test +import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.DisplayName @DisplayName("Login page should") -internal class LoginPageUITest : IntegrationTest() { +@OptIn(ExperimentalTestApi::class) +internal class LoginPageUiTest : IntegrationTest() { + internal companion object { + private val client = createTestClient() + + @AfterAll + @JvmStatic + internal fun closeClient() { + client.close() + } + } + + private val username = "MykytaPimonovTD" private val SemanticsNodeInteractionsProvider.loginButton get() = onNodeWithTag("login-button") @@ -49,8 +66,13 @@ internal class LoginPageUITest : IntegrationTest() { private val SemanticsNodeInteractionsProvider.usernameInput get() = onNodeWithTag("username-input") + private val SemanticsNodeInteractionsProvider.submitButton + get() = onNodeWithTag("submit-button") + + private val SemanticsNodeInteractionsProvider.noResponseMessage + get() = onNodeWithTag("no-response-message") + @Test - @OptIn(ExperimentalTestApi::class) internal fun `have login button enabled only when a valid username is entered`() = runComposeUiTest { runApp() @@ -58,8 +80,43 @@ internal class LoginPageUITest : IntegrationTest() { usernameInput.performTextInput("()+$") loginButton.assertIsNotEnabled() usernameInput.performTextClearance() - usernameInput.performTextInput("MykytaPimonovTD") + usernameInput.performTextInput(username) delay() loginButton.assertIsEnabled() } + + @Test + internal fun `have submit button disabled after it is clicked, if no code has been entered`() = + runComposeUiTest { + runApp() + toVerificationPage() + submitButton.assertIsEnabled() + noResponseMessage.assertDoesNotExist() + submitButton.performClick() + delay() + submitButton.assertIsNotEnabled() + noResponseMessage.assertExists() + } + + @Test + internal fun `have submit button become available again 5 seconds after unsuccessful click`() = + runComposeUiTest { + runApp() + toVerificationPage() + submitButton.performClick() + delay(5100) + submitButton.assertIsEnabled() + noResponseMessage.assertDoesNotExist() + } + + private fun ComposeUiTest.toVerificationPage() { + usernameInput.performTextInput(username) + delay() + val observer = client.observeEvent(UserCodeReceived::class) + loginButton.performClick() + observer.waitUntilDone() + delay() + loginButton.assertDoesNotExist() + submitButton.assertExists() + } } diff --git a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/UITestEnv.kt b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/UiTestEnv.kt similarity index 85% rename from desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/UITestEnv.kt rename to desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/UiTestEnv.kt index be953cee..741f1144 100644 --- a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/UITestEnv.kt +++ b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/UiTestEnv.kt @@ -30,11 +30,12 @@ import androidx.compose.runtime.remember import androidx.compose.ui.test.ComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import com.google.common.util.concurrent.Uninterruptibles.sleepUninterruptibly +import io.spine.examples.pingh.client.TestClient import io.spine.examples.pingh.desktop.AppState import io.spine.examples.pingh.desktop.Theme import io.spine.examples.pingh.desktop.Window +import io.spine.examples.pingh.desktop.loadServerProperties import io.spine.examples.pingh.desktop.retrieveSystemSettings -import java.lang.Thread.sleep import java.util.concurrent.TimeUnit /** @@ -61,3 +62,14 @@ internal fun ComposeUiTest.runApp() { internal fun delay(millis: Long = 100) { sleepUninterruptibly(millis, TimeUnit.MILLISECONDS) } + +/** + * Loads connection data for the server and creates a test client to interact with it. + */ +internal fun createTestClient(): TestClient { + val properties = loadServerProperties() + return TestClient( + properties.getProperty("server.address"), + properties.getProperty("server.port").toInt() + ) +} From 06d76f459fd28cdc9cc25994aaef1342c28ea041 Mon Sep 17 00:00:00 2001 From: MykytaPimonovTD Date: Mon, 21 Oct 2024 12:35:40 +0300 Subject: [PATCH 06/14] Create `DelayedFactAssertion`. --- .../examples/pingh/client/DesktopClient.kt | 16 +-- .../spine/examples/pingh/client/TestClient.kt | 133 ------------------ .../spine/examples/pingh/desktop/AppState.kt | 42 +++--- .../examples/pingh/desktop/LoginPageUiTest.kt | 49 +++---- .../io/spine/examples/pingh/desktop/UiTest.kt | 61 ++++++++ .../desktop/given/DelayedFactAssertion.kt | 89 ++++++++++++ .../examples/pingh/desktop/given/UiTestEnv.kt | 45 +----- 7 files changed, 201 insertions(+), 234 deletions(-) delete mode 100644 client/src/main/kotlin/io/spine/examples/pingh/client/TestClient.kt create mode 100644 desktop/src/test/kotlin/io/spine/examples/pingh/desktop/UiTest.kt create mode 100644 desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/DelayedFactAssertion.kt diff --git a/client/src/main/kotlin/io/spine/examples/pingh/client/DesktopClient.kt b/client/src/main/kotlin/io/spine/examples/pingh/client/DesktopClient.kt index eb76e7d4..8b1b6a6b 100644 --- a/client/src/main/kotlin/io/spine/examples/pingh/client/DesktopClient.kt +++ b/client/src/main/kotlin/io/spine/examples/pingh/client/DesktopClient.kt @@ -136,16 +136,14 @@ internal class DesktopClient( */ internal fun observeEvent( type: KClass, - filter: EventFilter? = null, + filter: EventFilter, onEmit: (event: E) -> Unit ): Subscription = - with(clientRequest().subscribeToEvent(type.java)) { - if (filter != null) { - where(filter) - } - observe(onEmit) - post() - } + clientRequest() + .subscribeToEvent(type.java) + .where(filter) + .observe(onEmit) + .post() /** * Subscribes to the event of the provided type and cancels itself after @@ -195,7 +193,7 @@ internal class DesktopClient( * * @param subscription The subscription to be canceled. */ - internal fun cancel(subscription: Subscription) { + private fun cancel(subscription: Subscription) { client.subscriptions() .cancel(subscription) } diff --git a/client/src/main/kotlin/io/spine/examples/pingh/client/TestClient.kt b/client/src/main/kotlin/io/spine/examples/pingh/client/TestClient.kt deleted file mode 100644 index 1009e319..00000000 --- a/client/src/main/kotlin/io/spine/examples/pingh/client/TestClient.kt +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright 2024, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package io.spine.examples.pingh.client - -import com.google.common.annotations.VisibleForTesting -import io.grpc.ManagedChannel -import io.grpc.ManagedChannelBuilder -import io.spine.base.EventMessage -import io.spine.core.UserId -import java.util.concurrent.CompletableFuture -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException -import kotlin.reflect.KClass - -/** - * A client for interacting with the Pingh server for testing. - * - * This client provides the ability to subscribe to events and wait for their emission. - * - * @param address The address of the Pingh server. - * @param port The port on which the Pingh server is running. - */ -@VisibleForTesting -public class TestClient(address: String, port: Int) { - public companion object { - /** - * The default amount of seconds to wait - * when [closing][ManagedChannel.shutdown] the channel. - */ - private const val defaultShutdownTimeout = 5L - } - - /** - * Channel for the communication with the Pingh server. - */ - private val channel = ManagedChannelBuilder - .forAddress(address, port) - .usePlaintext() - .build() - - /** - * Enables interaction with the Pingh server. - */ - private val client = DesktopClient( - channel, - UserId.newBuilder() - .setValue("test-client") - .build() - ) - - /** - * Starts observing the emission of the passed event. - * - * Subscribes to an observable event. When this event is emitted, - * the [future][CompletableFuture] is considered complete. - * - * It is crucial to begin observing an event before sending a command - * that triggers its emission. Otherwise, there may be a wait for an event - * that has already been emitted. - * - * @param E The type of the observable event. - * - * @param event The class of the type of the observable event. - */ - public fun observeEvent(event: KClass): Observer { - val future = CompletableFuture() - val subscription = client.observeEvent(event) { - future.complete(Unit) - } - return Observer(future) { client.cancel(subscription) } - } - - /** - * Closes the client. - */ - public fun close() { - client.close() - channel.shutdown() - .awaitTermination(defaultShutdownTimeout, TimeUnit.SECONDS) - } -} - -/** - * An observer for event emission. - * - * Contains a `CompletableFuture` that will be marked as completed - * when the event is emitted. - */ -@VisibleForTesting -public class Observer internal constructor( - private val future: CompletableFuture, - private val cancel: () -> Unit -) { - /** - * Waiting for the event to be emitted. - * - * If no emission occurs within the specified time, - * a [TimeoutException] will be thrown. - * - * @param millis The wait time for emission, specified in milliseconds. - */ - public fun waitUntilDone(millis: Long = 2000) { - try { - future.get(millis, TimeUnit.MILLISECONDS) - } finally { - cancel() - } - } -} diff --git a/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/AppState.kt b/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/AppState.kt index 5d1d4c85..acc29656 100644 --- a/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/AppState.kt +++ b/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/AppState.kt @@ -63,13 +63,33 @@ internal class AppState(settings: SystemSettings) { init { val notificationSender = TrayNotificationSender(composeTray) { !window.isShown } - val properties = loadServerProperties() + val properties = loadProperties() app = PinghApplication.builder() .withAddress(properties.getProperty("server.address")) .withPort(properties.getProperty("server.port").toInt()) .with(notificationSender) .build() } + + /** + * Loads server properties from resource folder. + */ + private fun loadProperties(): Properties { + val properties = Properties() + val path = "/config/server.properties" + AppState::class.java.getResourceAsStream(path).use { + properties.load(it) + } + check(properties.containsKey("server.address")) { + "The Pingh server address must be provided in the configuration file " + + "located at \"resource$path\"." + } + check(properties.containsKey("server.port")) { + "The Pingh server port must be provided in the configuration file " + + "located at \"resource$path\"." + } + return properties + } } /** @@ -99,23 +119,3 @@ private class TrayNotificationSender( } } } - -/** - * Loads server properties from resource folder. - */ -internal fun loadServerProperties(): Properties { - val properties = Properties() - val path = "/config/server.properties" - AppState::class.java.getResourceAsStream(path).use { - properties.load(it) - } - check(properties.containsKey("server.address")) { - "The Pingh server address must be provided in the configuration file " + - "located at \"resource$path\"." - } - check(properties.containsKey("server.port")) { - "The Pingh server port must be provided in the configuration file " + - "located at \"resource$path\"." - } - return properties -} diff --git a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/LoginPageUiTest.kt b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/LoginPageUiTest.kt index 4193127c..d0d02c4e 100644 --- a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/LoginPageUiTest.kt +++ b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/LoginPageUiTest.kt @@ -36,27 +36,15 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.runComposeUiTest -import io.spine.examples.pingh.desktop.given.createTestClient +import io.spine.examples.pingh.desktop.given.DelayedFactAssertion.Companion.awaitFact import io.spine.examples.pingh.desktop.given.delay -import io.spine.examples.pingh.desktop.given.runApp -import io.spine.examples.pingh.sessions.event.UserCodeReceived -import io.spine.examples.pingh.testing.client.IntegrationTest import kotlin.test.Test -import org.junit.jupiter.api.AfterAll +import kotlin.time.Duration.Companion.seconds import org.junit.jupiter.api.DisplayName @DisplayName("Login page should") @OptIn(ExperimentalTestApi::class) -internal class LoginPageUiTest : IntegrationTest() { - internal companion object { - private val client = createTestClient() - - @AfterAll - @JvmStatic - internal fun closeClient() { - client.close() - } - } +internal class LoginPageUiTest : UiTest() { private val username = "MykytaPimonovTD" @@ -78,11 +66,10 @@ internal class LoginPageUiTest : IntegrationTest() { runApp() loginButton.assertIsNotEnabled() usernameInput.performTextInput("()+$") - loginButton.assertIsNotEnabled() + awaitFact { loginButton.assertIsNotEnabled() } usernameInput.performTextClearance() usernameInput.performTextInput(username) - delay() - loginButton.assertIsEnabled() + awaitFact { loginButton.assertIsEnabled() } } @Test @@ -93,9 +80,10 @@ internal class LoginPageUiTest : IntegrationTest() { submitButton.assertIsEnabled() noResponseMessage.assertDoesNotExist() submitButton.performClick() - delay() - submitButton.assertIsNotEnabled() - noResponseMessage.assertExists() + awaitFact { + submitButton.assertIsNotEnabled() + noResponseMessage.assertExists() + } } @Test @@ -104,19 +92,20 @@ internal class LoginPageUiTest : IntegrationTest() { runApp() toVerificationPage() submitButton.performClick() - delay(5100) - submitButton.assertIsEnabled() - noResponseMessage.assertDoesNotExist() + delay(5.seconds) + awaitFact { + submitButton.assertIsEnabled() + noResponseMessage.assertDoesNotExist() + } } private fun ComposeUiTest.toVerificationPage() { usernameInput.performTextInput(username) - delay() - val observer = client.observeEvent(UserCodeReceived::class) + awaitFact { loginButton.assertIsEnabled() } loginButton.performClick() - observer.waitUntilDone() - delay() - loginButton.assertDoesNotExist() - submitButton.assertExists() + awaitFact { + loginButton.assertDoesNotExist() + submitButton.assertExists() + } } } diff --git a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/UiTest.kt b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/UiTest.kt new file mode 100644 index 00000000..62f1004a --- /dev/null +++ b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/UiTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2024, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.examples.pingh.desktop + +import androidx.compose.runtime.remember +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import io.spine.examples.pingh.testing.client.IntegrationTest +import org.junit.jupiter.api.AfterEach + +/** + * Abstract base for UI tests that require a server to run and + * a client application connected to it. + */ +internal abstract class UiTest : IntegrationTest() { + + private var state: AppState? = null + + @AfterEach + internal fun shutdownChannel() { + state?.app?.close() + } + + /** + * Launches the Pingh application for testing and sets application state for this test case. + */ + @OptIn(ExperimentalTestApi::class) + protected fun ComposeUiTest.runApp() { + setContent { + Theme { + val settings = retrieveSystemSettings() + state = remember { AppState(settings) } + Window(state!!.window, state!!.app) + } + } + } +} diff --git a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/DelayedFactAssertion.kt b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/DelayedFactAssertion.kt new file mode 100644 index 00000000..e0a54db7 --- /dev/null +++ b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/DelayedFactAssertion.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2024, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.examples.pingh.desktop.given + +import java.lang.AssertionError +import java.util.concurrent.CompletableFuture +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * Checks whether the specified event has occurred within a given time period. + * + * Compose uses coroutines to update UI elements, which means that updates + * may not occur immediately after a state change. Therefore, it is advisable to introduce + * a brief delay before checking for state updates. + * + * @param assertion The assertion that must be checked. + */ +internal class DelayedFactAssertion private constructor(private val assertion: () -> Unit) { + internal companion object { + /** + * The waiting period before performing the next check after an unsuccessful attempt. + */ + private val intervalBetweenChecks = 100.milliseconds + + /** + * Waits for the delayed fact to occur by repeatedly performing the `assertion`. + * + * Throws exception if no attempt was successful in the given `duration`. + * + * @param duration The duration within which a successful check must occur. + * @param assertion The assertion that must be checked. + */ + internal fun awaitFact(duration: Duration = 5.seconds, assertion: () -> Unit) { + DelayedFactAssertion(assertion).awaitFact(duration) + } + } + + /** + * The recent error that occurred during checking before the allocated time expired. + */ + private var error: AssertionError? = null + + /** + * Performs periodic assertion checks. + * + * Terminates if a check succeeds. However, if no successful check occurs before + * the check `duration` ends, throws an exception. + */ + private fun awaitFact(duration: Duration) { + val timer = CompletableFuture.supplyAsync { delay(duration) } + while (!timer.isDone) { + try { + assertion() + return + } catch (e: AssertionError) { + error = e + } finally { + delay(intervalBetweenChecks) + } + } + throw error ?: IllegalStateException("No checks have been made.") + } +} diff --git a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/UiTestEnv.kt b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/UiTestEnv.kt index 741f1144..c7b87d77 100644 --- a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/UiTestEnv.kt +++ b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/UiTestEnv.kt @@ -26,50 +26,13 @@ package io.spine.examples.pingh.desktop.given -import androidx.compose.runtime.remember -import androidx.compose.ui.test.ComposeUiTest -import androidx.compose.ui.test.ExperimentalTestApi import com.google.common.util.concurrent.Uninterruptibles.sleepUninterruptibly -import io.spine.examples.pingh.client.TestClient -import io.spine.examples.pingh.desktop.AppState -import io.spine.examples.pingh.desktop.Theme -import io.spine.examples.pingh.desktop.Window -import io.spine.examples.pingh.desktop.loadServerProperties -import io.spine.examples.pingh.desktop.retrieveSystemSettings import java.util.concurrent.TimeUnit +import kotlin.time.Duration /** - * Launches the Pingh application for testing. + * Causes the currently executing thread to sleep for the specified duration. */ -@OptIn(ExperimentalTestApi::class) -internal fun ComposeUiTest.runApp() { - setContent { - Theme { - val settings = retrieveSystemSettings() - val state = remember { AppState(settings) } - Window(state.window, state.app) - } - } -} - -/** - * Causes the currently executing thread to sleep for the specified number of milliseconds - * - * Compose uses coroutines to update UI elements, which means that updates - * may not occur immediately after a state change. Therefore, it is advisable to introduce - * a brief delay before checking for state updates. - */ -internal fun delay(millis: Long = 100) { - sleepUninterruptibly(millis, TimeUnit.MILLISECONDS) -} - -/** - * Loads connection data for the server and creates a test client to interact with it. - */ -internal fun createTestClient(): TestClient { - val properties = loadServerProperties() - return TestClient( - properties.getProperty("server.address"), - properties.getProperty("server.port").toInt() - ) +internal fun delay(duration: Duration) { + sleepUninterruptibly(duration.inWholeMilliseconds, TimeUnit.MILLISECONDS) } From 6487ece6092650fa806e1226af6dac5a31b8594d Mon Sep 17 00:00:00 2001 From: MykytaPimonovTD Date: Mon, 21 Oct 2024 12:36:06 +0300 Subject: [PATCH 07/14] Update versions. --- .../src/main/kotlin/io/spine/internal/dependency/Pingh.kt | 2 +- version.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Pingh.kt b/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Pingh.kt index 5cf0e05e..be772b90 100644 --- a/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Pingh.kt +++ b/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Pingh.kt @@ -28,7 +28,7 @@ package io.spine.internal.dependency // https://github.com/spine-examples/Pingh public object Pingh { - private const val version = "1.0.0-SNAPSHOT.8" + private const val version = "1.0.0-SNAPSHOT.9" private const val group = "io.spine.examples.pingh" public const val client: String = "$group:client:$version" diff --git a/version.gradle.kts b/version.gradle.kts index 854c5f4f..08686f70 100644 --- a/version.gradle.kts +++ b/version.gradle.kts @@ -27,4 +27,4 @@ /** * The version of the `Pingh` to publish. */ -val pinghVersion: String by extra("1.0.0-SNAPSHOT.8") +val pinghVersion: String by extra("1.0.0-SNAPSHOT.9") From 5cdcd443b5d267308d3b027eca0a70ee98ed17f0 Mon Sep 17 00:00:00 2001 From: MykytaPimonovTD Date: Mon, 21 Oct 2024 12:37:56 +0300 Subject: [PATCH 08/14] Update `README`. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 795afc33..e3636767 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ There are several auxiliary modules available for testing: - `testutil-sessions` allows authentication to the app without using the GitHub REST API. - `testutil-mentions` allows retrieving new user mentions without using the GitHub REST API. +- `testutil-client` provides the ability to run a Pingh server for testing. For a detailed analysis of the processes within domain contexts, refer to the [#EventStorming documentation](./EventStorming.md). From 7895c7d0db5e093dc19bb41bbdb51cbf3629d83e Mon Sep 17 00:00:00 2001 From: MykytaPimonovTD Date: Mon, 21 Oct 2024 12:52:44 +0300 Subject: [PATCH 09/14] Clean up. --- .../kotlin/io/spine/examples/pingh/desktop/AppState.kt | 2 +- .../pingh/desktop/given/DelayedFactAssertion.kt | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/AppState.kt b/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/AppState.kt index acc29656..ba11bb56 100644 --- a/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/AppState.kt +++ b/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/AppState.kt @@ -77,7 +77,7 @@ internal class AppState(settings: SystemSettings) { private fun loadProperties(): Properties { val properties = Properties() val path = "/config/server.properties" - AppState::class.java.getResourceAsStream(path).use { + javaClass.getResourceAsStream(path).use { properties.load(it) } check(properties.containsKey("server.address")) { diff --git a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/DelayedFactAssertion.kt b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/DelayedFactAssertion.kt index e0a54db7..60a6bd44 100644 --- a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/DelayedFactAssertion.kt +++ b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/DelayedFactAssertion.kt @@ -33,11 +33,11 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds /** - * Checks whether the specified event has occurred within a given time period. + * Checks whether the specified assertion has been successful within the given time period. * * Compose uses coroutines to update UI elements, which means that updates - * may not occur immediately after a state change. Therefore, it is advisable to introduce - * a brief delay before checking for state updates. + * may not occur immediately after a state change. Therefore, periodic checking over + * a specified duration is essential. * * @param assertion The assertion that must be checked. */ @@ -67,9 +67,9 @@ internal class DelayedFactAssertion private constructor(private val assertion: ( private var error: AssertionError? = null /** - * Performs periodic assertion checks. + * Performs periodic `assertion` checks. * - * Terminates if a check succeeds. However, if no successful check occurs before + * Terminates if a check succeeds. If no successful check occurs before * the check `duration` ends, throws an exception. */ private fun awaitFact(duration: Duration) { From 94c331dc2884c85868c8711bd8f492120133b470 Mon Sep 17 00:00:00 2001 From: MykytaPimonovTD Date: Mon, 21 Oct 2024 13:08:11 +0300 Subject: [PATCH 10/14] Fix detekt warnings. --- .../spine/examples/pingh/desktop/given/DelayedFactAssertion.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/DelayedFactAssertion.kt b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/DelayedFactAssertion.kt index 60a6bd44..db18448c 100644 --- a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/DelayedFactAssertion.kt +++ b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/given/DelayedFactAssertion.kt @@ -84,6 +84,6 @@ internal class DelayedFactAssertion private constructor(private val assertion: ( delay(intervalBetweenChecks) } } - throw error ?: IllegalStateException("No checks have been made.") + throw error ?: AssertionError("No checks have been made.") } } From fe2d16af1b28f1326cbb09721d40f0e43f190fde Mon Sep 17 00:00:00 2001 From: MykytaPimonovTD Date: Mon, 21 Oct 2024 13:14:34 +0300 Subject: [PATCH 11/14] Add suppression for `IntegrationTest`. --- .../io/spine/examples/pingh/testing/client/IntegrationTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/testutil-client/src/main/kotlin/io/spine/examples/pingh/testing/client/IntegrationTest.kt b/testutil-client/src/main/kotlin/io/spine/examples/pingh/testing/client/IntegrationTest.kt index 8d8b9b61..29e1cbd2 100644 --- a/testutil-client/src/main/kotlin/io/spine/examples/pingh/testing/client/IntegrationTest.kt +++ b/testutil-client/src/main/kotlin/io/spine/examples/pingh/testing/client/IntegrationTest.kt @@ -47,6 +47,7 @@ import org.junit.jupiter.api.BeforeEach * * Also provides a [PinghApplication] for interacting with the `Server`. */ +@Suppress("UnnecessaryAbstractClass" /* Avoids creating instances; only for inheritance. */) public abstract class IntegrationTest { internal companion object { From a0b9c7003a395a89dd361de99cc0beecbfed5284 Mon Sep 17 00:00:00 2001 From: MykytaPimonovTD Date: Mon, 21 Oct 2024 14:44:13 +0300 Subject: [PATCH 12/14] Add virtual display for GitHub workflow. --- .github/workflows/build-under-ubuntu.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/build-under-ubuntu.yml b/.github/workflows/build-under-ubuntu.yml index e5f3dc72..1bc9093e 100644 --- a/.github/workflows/build-under-ubuntu.yml +++ b/.github/workflows/build-under-ubuntu.yml @@ -26,9 +26,18 @@ jobs: env: JAVA_HOME: ${{ env.JAVA_HOME_11_X64 }} + - name: Install Xvfb + run: | + sudo apt-get update + sudo apt-get install -y xvfb + + - name: Start Xvfb + run: Xvfb :99 -ac & + - name: Build client with JDK 17 run: | cd desktop ./gradlew build --stacktrace env: JAVA_HOME: ${{ env.JAVA_HOME_17_X64 }} + DISPLAY: ":99" From 836b64ba4d0633c6d061cd3821bba5e856e170c0 Mon Sep 17 00:00:00 2001 From: MykytaPimonovTD Date: Mon, 21 Oct 2024 15:07:24 +0300 Subject: [PATCH 13/14] Delete useless installation. --- .github/workflows/build-under-ubuntu.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/build-under-ubuntu.yml b/.github/workflows/build-under-ubuntu.yml index 1bc9093e..d506b603 100644 --- a/.github/workflows/build-under-ubuntu.yml +++ b/.github/workflows/build-under-ubuntu.yml @@ -26,13 +26,8 @@ jobs: env: JAVA_HOME: ${{ env.JAVA_HOME_11_X64 }} - - name: Install Xvfb - run: | - sudo apt-get update - sudo apt-get install -y xvfb - - name: Start Xvfb - run: Xvfb :99 -ac & + run: Xvfb :99 & - name: Build client with JDK 17 run: | From 34e50835121c46c9fdd5d962363f4491fc79a864 Mon Sep 17 00:00:00 2001 From: MykytaPimonovTD Date: Mon, 21 Oct 2024 18:50:54 +0300 Subject: [PATCH 14/14] Improve `README`. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e3636767..781ffc88 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ There are several auxiliary modules available for testing: - `testutil-sessions` allows authentication to the app without using the GitHub REST API. - `testutil-mentions` allows retrieving new user mentions without using the GitHub REST API. -- `testutil-client` provides the ability to run a Pingh server for testing. +- `testutil-client` is a utility code for client-side testing. For a detailed analysis of the processes within domain contexts, refer to the [#EventStorming documentation](./EventStorming.md).