From 7400da9460934b56cc07d37dc6f669edc07b2a59 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Mon, 29 Apr 2024 15:14:33 +0800 Subject: [PATCH] add first simple end-to-end UX request response test --- settings.gradle.kts | 1 + .../multiplatform/hellohttp/ux/AppView.kt | 4 +- .../hellohttp/ux/CodeEditorView.kt | 9 + .../hellohttp/ux/ProjectAndEnvironmentView.kt | 9 +- .../hellohttp/ux/RequestEditorView.kt | 3 + .../hellohttp/ux/RequestTreeView.kt | 2 + .../hellohttp/ux/ResponseViewerView.kt | 9 +- .../multiplatform/hellohttp/ux/TestTag.kt | 16 ++ ux-and-transport-test/build.gradle.kts | 39 ++++ .../hellohttp/test/RequestResponseTest.kt | 175 ++++++++++++++++++ 10 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/TestTag.kt create mode 100644 ux-and-transport-test/build.gradle.kts create mode 100644 ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTest.kt diff --git a/settings.gradle.kts b/settings.gradle.kts index 1d0906f2..76f62aa8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,3 +18,4 @@ rootProject.name = "hello-http" include("test-server") include("test-common") +include("ux-and-transport-test") diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/AppView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/AppView.kt index c5ec2b77..5ed9adf3 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/AppView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/AppView.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -107,7 +108,7 @@ fun AppView() { val dialogViewModel = AppContext.DialogViewModel val dialogState = dialogViewModel.state.collectAsState().value // needed for updating UI by flow log.d { "Dialog State = $dialogState" } - Box(modifier = Modifier.background(colors.background).fillMaxSize()) { + Box(modifier = Modifier.background(colors.background).fillMaxSize().testTag(TestTag.ContainerView.name)) { AppContentView() dialogState?.let { dialog -> @@ -148,6 +149,7 @@ fun AppView() { .align( Alignment.Center ) + .testTag(TestTag.DialogContainerView.name) ) { LaunchedEffect(Unit) { focusRequester.requestFocus() diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 87397061..29680bbc 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextRange @@ -90,6 +91,7 @@ fun CodeEditorView( transformations: List = emptyList(), isEnableVariables: Boolean = false, knownVariables: Set = setOf(), + testTag: String? = null, ) { val colors: TextFieldColors = TextFieldDefaults.textFieldColors( textColor = textColor, @@ -477,6 +479,13 @@ fun CodeEditorView( this } } + .run { + if (testTag != null) { + testTag(testTag) + } else { + this + } + } ) } VerticalScrollbar( diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ProjectAndEnvironmentView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ProjectAndEnvironmentView.kt index f7f7f7ad..0e37b97a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ProjectAndEnvironmentView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ProjectAndEnvironmentView.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.sunnychung.application.multiplatform.hellohttp.model.Environment import com.sunnychung.application.multiplatform.hellohttp.model.Project @@ -144,12 +145,14 @@ fun ProjectAndEnvironmentViewV2( false } } - .defaultMinSize(minWidth = 200.dp), + .defaultMinSize(minWidth = 200.dp) + .testTag(TestTag.ProjectNameAndSubprojectNameDialogTextField.name), ) AppTextButton( text = "Done", onClick = { onDone() }, - modifier = Modifier.padding(top = 4.dp), + modifier = Modifier.padding(top = 4.dp) + .testTag(TestTag.ProjectNameAndSubprojectNameDialogDoneButton.name), ) } @@ -205,6 +208,7 @@ fun ProjectAndEnvironmentViewV2( dialogTextFieldValue = "" dialogIsCreate = true } + .testTag(TestTag.FirstTimeCreateProjectButton.name) ) } else { LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { @@ -287,6 +291,7 @@ fun ProjectAndEnvironmentViewV2( dialogTextFieldValue = "" dialogIsCreate = true } + .testTag(TestTag.FirstTimeCreateSubprojectButton.name) ) } else { LazyColumn( diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt index a4a67aad..629fb0bd 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation @@ -286,6 +287,7 @@ fun RequestEditorView( ), singleLine = true, modifier = Modifier.weight(1f).padding(vertical = 4.dp) + .testTag(TestTag.RequestUrlTextField.name) ) val isOneOffRequest = when (request.application) { @@ -327,6 +329,7 @@ fun RequestEditorView( } } .padding(start = 10.dp, end = if (dropdownItems.isNotEmpty()) 4.dp else 10.dp) + .testTag(TestTag.RequestFireOrDisconnectButton.name) ) { AppText( text = label, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestTreeView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestTreeView.kt index 763d19ce..0f1c2b82 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestTreeView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestTreeView.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.sunnychung.application.multiplatform.hellohttp.model.MoveDirection @@ -469,6 +470,7 @@ fun RequestTreeView( true }, modifier = Modifier.padding(4.dp) + .testTag(TestTag.CreateRequestOrFolderButton.name) ) } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt index dc4214a4..695cbc07 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign @@ -340,7 +341,12 @@ fun StatusLabel(modifier: Modifier = Modifier, response: UserResponse, connectio Pair("", colors.errorResponseBackground) } if (text.isNotEmpty()) { - DataLabel(modifier = modifier, text = text, backgroundColor = backgroundColor, textColor = colors.bright) + DataLabel( + modifier = modifier.testTag(TestTag.ResponseStatus.name), + text = text, + backgroundColor = backgroundColor, + textColor = colors.bright, + ) } } @@ -539,6 +545,7 @@ fun BodyViewerView( } else { emptyList() }, + testTag = TestTag.ResponseBody.name, ) } } else { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/TestTag.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/TestTag.kt new file mode 100644 index 00000000..4829c4d6 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/TestTag.kt @@ -0,0 +1,16 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux + +enum class TestTag { + ContainerView, + DialogContainerView, + ProjectNameAndSubprojectNameDialogTextField, + ProjectNameAndSubprojectNameDialogDoneButton, + FirstTimeCreateProjectButton, + FirstTimeCreateSubprojectButton, + CreateRequestOrFolderButton, + RequestMethodDropdownButton, + RequestUrlTextField, + RequestFireOrDisconnectButton, + ResponseStatus, + ResponseBody, +} diff --git a/ux-and-transport-test/build.gradle.kts b/ux-and-transport-test/build.gradle.kts new file mode 100644 index 00000000..bf90689f --- /dev/null +++ b/ux-and-transport-test/build.gradle.kts @@ -0,0 +1,39 @@ +import org.gradle.api.tasks.testing.logging.TestLogEvent + +plugins { + kotlin("jvm") + id("org.jetbrains.compose") +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 +} + +repositories { + google() + mavenCentral() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") +} + +dependencies { + testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.2") + testImplementation("org.junit.jupiter:junit-jupiter-engine:5.10.2") + + testImplementation(project(":test-common")) + testImplementation(rootProject) + testImplementation("io.github.sunny-chung:kdatetime-multiplatform:1.0.0") + implementation("com.fasterxml.jackson.core:jackson-databind:2.15.2") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2") + + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + testImplementation(compose.uiTest) + testImplementation(compose.desktop.currentOs) +} + +tasks.withType { + useJUnitPlatform() + + testLogging { + events = setOf(TestLogEvent.STARTED, TestLogEvent.FAILED, TestLogEvent.PASSED, TestLogEvent.SKIPPED) + } +} diff --git a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTest.kt b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTest.kt new file mode 100644 index 00000000..406a7a77 --- /dev/null +++ b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTest.kt @@ -0,0 +1,175 @@ +@file:OptIn(ExperimentalTestApi::class) + +package com.sunnychung.application.multiplatform.hellohttp.test + +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasTextExactly +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.waitUntilExactlyOneExists +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.rememberWindowState +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.sunnychung.application.multiplatform.hellohttp.AppContext +import com.sunnychung.application.multiplatform.hellohttp.model.UserRequestTemplate +import com.sunnychung.application.multiplatform.hellohttp.platform.isMacOs +import com.sunnychung.application.multiplatform.hellohttp.test.payload.RequestData +import com.sunnychung.application.multiplatform.hellohttp.util.uuidString +import com.sunnychung.application.multiplatform.hellohttp.ux.AppView +import com.sunnychung.application.multiplatform.hellohttp.ux.TestTag +import com.sunnychung.lib.multiplatform.kdatetime.KDuration +import com.sunnychung.lib.multiplatform.kdatetime.extension.seconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import java.awt.Dimension +import java.io.File + +class RequestResponseTest { + + companion object { + @BeforeAll + @JvmStatic + fun initTests() { + val appDir = File("build/testrun/data") + AppContext.dataDir = appDir + AppContext.SingleInstanceProcessService.apply { dataDir = appDir }.enforce() + runBlocking { + AppContext.PersistenceManager.initialize() + } + } + + val echoUrl = "http://localhost:18081/rest/echo" + + + } + + @Test + fun echoGet() = runTest { + createAndSendHttpRequest( + UserRequestTemplate( + id = uuidString(), + method = "GET", + url = echoUrl, + ) + ) + onNodeWithTag(TestTag.ResponseStatus.name).assertTextEquals("200 OK") + val responseBody = onNodeWithTag(TestTag.ResponseBody.name).fetchSemanticsNode() + .getTexts() + .single() + val resp = jacksonObjectMapper().readValue(responseBody, RequestData::class.java) + assertEquals("GET", resp.method) + assertEquals("/rest/echo", resp.path) + assertTrue { resp.headers.size > 1 } + assertEquals(0, resp.queryParameters.size) + assertEquals(0, resp.formData.size) + assertEquals(0, resp.multiparts.size) + assertEquals(null, resp.body) + } +} + +fun runTest(testBlock: suspend ComposeUiTest.() -> Unit) = + runComposeUiTest { + setContent { + Window( + title = "Hello HTTP", + onCloseRequest = {}, + state = rememberWindowState(width = 1024.dp, height = 560.dp) + ) { + with(LocalDensity.current) { + window.minimumSize = if (isMacOs()) { + Dimension(800, 450) + } else { + Dimension(800.dp.roundToPx(), 450.dp.roundToPx()) + } + } + AppView() + } + } + runBlocking { + testBlock() + } + } + +fun ComposeUiTest.createProjectIfNeeded() { + if (onAllNodesWithTag(TestTag.FirstTimeCreateProjectButton.name).fetchSemanticsNodes().isNotEmpty()) { + // create first project + onNodeWithTag(TestTag.FirstTimeCreateProjectButton.name) + .performClick() + waitUntilExactlyOneExists(hasTestTag(TestTag.ProjectNameAndSubprojectNameDialogTextField.name), 500L) + onNodeWithTag(TestTag.ProjectNameAndSubprojectNameDialogTextField.name) + .performTextInput("Test Project") + waitForIdle() + onNodeWithTag(TestTag.ProjectNameAndSubprojectNameDialogDoneButton.name) + .performClick() + + // create first subproject + waitUntilExactlyOneExists(hasTestTag(TestTag.FirstTimeCreateSubprojectButton.name), 500L) + onNodeWithTag(TestTag.FirstTimeCreateSubprojectButton.name) + .performClick() + waitUntilExactlyOneExists(hasTestTag(TestTag.ProjectNameAndSubprojectNameDialogTextField.name), 500L) + onNodeWithTag(TestTag.ProjectNameAndSubprojectNameDialogTextField.name) + .performTextInput("Test Subproject") + waitForIdle() + onNodeWithTag(TestTag.ProjectNameAndSubprojectNameDialogDoneButton.name) + .performClick() + + println("created first project and subproject") + } + waitUntilExactlyOneExists(hasTestTag(TestTag.CreateRequestOrFolderButton.name), 5000L) +} + +suspend fun ComposeUiTest.createAndSendHttpRequest(request: UserRequestTemplate, timeout: KDuration = 1.seconds(), isOneOffRequest: Boolean = true) { + createProjectIfNeeded() + + onNodeWithTag(TestTag.CreateRequestOrFolderButton.name) + .performClick() + waitUntilExactlyOneExists(hasTextExactly("Request", includeEditableText = false)) + onNodeWithText("Request") + .performClick() + waitUntilExactlyOneExists(hasTestTag(TestTag.RequestUrlTextField.name), 1000L) + + onNodeWithTag(TestTag.RequestUrlTextField.name) + .performTextInput(request.url) + + waitForIdle() +// mainClock.advanceTimeBy(500L) + delay(400L) + waitForIdle() // prevent illegal state after sleeping + + onNodeWithTag(TestTag.RequestFireOrDisconnectButton.name) + .performClick() + waitForIdle() + + // wait for response + delay(400L) + waitForIdle() + if (isOneOffRequest) { + waitUntil(maxOf(1L, timeout.millis)) { onAllNodesWithText("Communicating").fetchSemanticsNodes().isEmpty() } + } +} + +fun SemanticsNode.getTexts(): List { + val actual = mutableListOf() + config.getOrNull(SemanticsProperties.EditableText) + ?.let { actual.add(it.text) } + config.getOrNull(SemanticsProperties.Text) + ?.let { actual.addAll(it.map { anStr -> anStr.text }) } + return actual.filter { it.isNotBlank() } +}