From a5e5b9cae794c4d904fe45bce9254fab66c9d61e Mon Sep 17 00:00:00 2001 From: Andrey Shcheglov Date: Thu, 9 Feb 2023 12:01:13 +0300 Subject: [PATCH 1/2] WIP --- .../save/backend/SaveApplication.kt | 5 +- .../TestSuiteValidationController.kt | 108 ++++++++++++ .../save/test/TestSuiteValidationResult.kt | 28 +++ .../views/TestSuiteValidationResultView.kt | 59 +++++++ .../views/TestSuiteValidationView.kt | 163 ++++++++++++++++++ .../save/frontend/routing/BasicRouting.kt | 1 + 6 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 save-backend/src/main/kotlin/com/saveourtool/save/backend/controllers/TestSuiteValidationController.kt create mode 100644 save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/test/TestSuiteValidationResult.kt create mode 100644 save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/TestSuiteValidationResultView.kt create mode 100644 save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/TestSuiteValidationView.kt diff --git a/save-backend/src/main/kotlin/com/saveourtool/save/backend/SaveApplication.kt b/save-backend/src/main/kotlin/com/saveourtool/save/backend/SaveApplication.kt index 896cdf1a82..6699686c5d 100644 --- a/save-backend/src/main/kotlin/com/saveourtool/save/backend/SaveApplication.kt +++ b/save-backend/src/main/kotlin/com/saveourtool/save/backend/SaveApplication.kt @@ -6,9 +6,12 @@ import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.http.ResponseEntity import reactor.core.publisher.Flux +import reactor.core.publisher.ParallelFlux import java.nio.ByteBuffer -typealias ByteBufferFluxResponse = ResponseEntity> +internal typealias FluxResponse = ResponseEntity> +internal typealias ParallelFluxResponse = ResponseEntity> +internal typealias ByteBufferFluxResponse = FluxResponse /** * An entrypoint for spring for save-backend diff --git a/save-backend/src/main/kotlin/com/saveourtool/save/backend/controllers/TestSuiteValidationController.kt b/save-backend/src/main/kotlin/com/saveourtool/save/backend/controllers/TestSuiteValidationController.kt new file mode 100644 index 0000000000..42f97d894a --- /dev/null +++ b/save-backend/src/main/kotlin/com/saveourtool/save/backend/controllers/TestSuiteValidationController.kt @@ -0,0 +1,108 @@ +package com.saveourtool.save.backend.controllers + +import com.saveourtool.save.backend.ParallelFluxResponse +import com.saveourtool.save.backend.utils.withHttpHeaders +import com.saveourtool.save.configs.ApiSwaggerSupport +import com.saveourtool.save.test.TestSuiteValidationResult +import com.saveourtool.save.v1 +import io.swagger.v3.oas.annotations.responses.ApiResponse +import org.springframework.http.HttpHeaders.ACCEPT +import org.springframework.http.MediaType.APPLICATION_NDJSON_VALUE +import org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Flux +import reactor.core.publisher.ParallelFlux +import reactor.core.scheduler.Schedulers +import kotlin.streams.asStream +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Demonstrates _Server-Sent Events_ (SSE). + */ +@ApiSwaggerSupport +@RestController +@RequestMapping(path = ["/api/$v1/a"]) +class TestSuiteValidationController { + /** + * @return a stream of events. + */ + @GetMapping( + path = ["/validate"], + headers = [ + "$ACCEPT=$TEXT_EVENT_STREAM_VALUE", + "$ACCEPT=$APPLICATION_NDJSON_VALUE", + ], + produces = [ + TEXT_EVENT_STREAM_VALUE, + APPLICATION_NDJSON_VALUE, + ], + ) + @ApiResponse(responseCode = "406", description = "Could not find acceptable representation.") + fun sequential(): ParallelFluxResponse = + withHttpHeaders { + overallProgress() + } + + @Suppress("MAGIC_NUMBER") + private fun singleCheck( + checkId: String, + checkName: String, + duration: Duration, + ): Flux { + @Suppress("MagicNumber") + val ticks = 0..100 + + val delayMillis = duration.inWholeMilliseconds / (ticks.count() - 1) + + return Flux.fromStream(ticks.asSequence().asStream()) + .map { percentage -> + TestSuiteValidationResult( + checkId = checkId, + checkName = checkName, + percentage = percentage, + ) + } + .map { item -> + Thread.sleep(delayMillis) + item + } + .subscribeOn(Schedulers.boundedElastic()) + } + + @Suppress("MAGIC_NUMBER") + private fun overallProgress(): ParallelFlux { + @Suppress("ReactiveStreamsUnusedPublisher") + val checks = arrayOf( + singleCheck( + "check A", + "Searching for plug-ins with zero tests", + 10.seconds, + ), + + singleCheck( + "check B", + "Searching for test suites with wildcard mode", + 20.seconds, + ), + + singleCheck( + "check C", + "Ordering pizza from the nearest restaurant", + 30.seconds, + ), + ) + + return when { + checks.isEmpty() -> Flux.empty().parallel() + + else -> checks.reduce { left, right -> + left.mergeWith(right) + } + .parallel(Runtime.getRuntime().availableProcessors()) + .runOn(Schedulers.parallel()) + } + } +} diff --git a/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/test/TestSuiteValidationResult.kt b/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/test/TestSuiteValidationResult.kt new file mode 100644 index 0000000000..30c6eaa4e6 --- /dev/null +++ b/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/test/TestSuiteValidationResult.kt @@ -0,0 +1,28 @@ +package com.saveourtool.save.test + +import kotlinx.serialization.Serializable + +/** + * @property checkId the unique check id. + * @property checkName the human-readable check name. + * @property percentage the completion percentage (`0..100`). + */ +@Serializable +data class TestSuiteValidationResult( + val checkId: String, + val checkName: String, + val percentage: Int +) { + init { + @Suppress("MAGIC_NUMBER") + require(percentage in 0..100) { + percentage.toString() + } + } + + override fun toString(): String = + when (percentage) { + 100 -> "Check $checkName is complete." + else -> "Check $checkName is running, $percentage% complete\u2026" + } +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/TestSuiteValidationResultView.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/TestSuiteValidationResultView.kt new file mode 100644 index 0000000000..d957deab45 --- /dev/null +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/TestSuiteValidationResultView.kt @@ -0,0 +1,59 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.components.views + +import com.saveourtool.save.test.TestSuiteValidationResult +import csstype.ClassName +import csstype.WhiteSpace +import csstype.Width +import js.core.jso +import react.FC +import react.Props +import react.dom.aria.AriaRole +import react.dom.aria.ariaValueMax +import react.dom.aria.ariaValueMin +import react.dom.aria.ariaValueNow +import react.dom.html.ReactHTML.div + +@Suppress( + "MagicNumber", + "MAGIC_NUMBER", +) +val testSuiteValidationResultView: FC = FC { props -> + props.validationResults.forEach { item -> + div { + div { + className = ClassName("progress progress-sm mr-2") + div { + className = ClassName("progress-bar bg-info") + role = "progressbar".unsafeCast() + style = jso { + width = "${item.percentage}%".unsafeCast() + } + ariaValueMin = 0.0 + ariaValueNow = item.percentage.toDouble() + ariaValueMax = 100.0 + } + } + div { + style = jso { + whiteSpace = "pre".unsafeCast() + } + + +item.toString() + } + } + } +} + +/** + * Properties for [testSuiteValidationResultView]. + * + * @see testSuiteValidationResultView + */ +external interface TestSuiteValidationResultProps : Props { + /** + * Test suite validation results. + */ + var validationResults: Collection +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/TestSuiteValidationView.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/TestSuiteValidationView.kt new file mode 100644 index 0000000000..9ba277be17 --- /dev/null +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/TestSuiteValidationView.kt @@ -0,0 +1,163 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") + +package com.saveourtool.save.frontend.components.views + +import com.saveourtool.save.frontend.utils.apiUrl +import com.saveourtool.save.frontend.utils.asMouseEventHandler +import com.saveourtool.save.frontend.utils.useDeferredEffect +import com.saveourtool.save.frontend.utils.useEventStream +import com.saveourtool.save.frontend.utils.useNdjson +import com.saveourtool.save.test.TestSuiteValidationResult +import csstype.BackgroundColor +import csstype.Border +import csstype.ColorProperty +import csstype.Height +import csstype.MinHeight +import csstype.Width +import js.core.jso +import react.ChildrenBuilder +import react.VFC +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.pre +import react.useState +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json + +private const val READY = "Ready." + +private const val DONE = "Done." + +@Suppress("LOCAL_VARIABLE_EARLY_DECLARATION") +val testSuiteValidationView: VFC = VFC { + var errorText by useState(initialValue = null) + + var rawResponse by useState(initialValue = null) + + /* + * When dealing with containers, avoid using `by useState()`. + */ + val (validationResults, setValidationResults) = useState(initialValue = emptyMap()) + + /** + * Updates the validation results. + * + * @param value the validation result to add to the state of this component. + */ + operator fun ChildrenBuilder.plusAssign( + value: TestSuiteValidationResult + ) { + /* + * When adding items to a container, prefer a lambda form of `StateSetter.invoke()`. + */ + setValidationResults { oldValidationResults -> + /* + * Preserve the order of keys in the map. + */ + linkedMapOf().apply { + putAll(oldValidationResults) + this[value.checkId] = value + } + } + } + + /** + * Clears the validation results. + * + * @return [Unit] + */ + fun clearResults() = + setValidationResults(emptyMap()) + + val init = { + errorText = null + rawResponse = "Awaiting server response..." + clearResults() + } + + div { + id = "test-suite-validation-status" + + style = jso { + border = "1px solid f0f0f0".unsafeCast() + width = "100%".unsafeCast() + height = "100%".unsafeCast() + minHeight = "600px".unsafeCast() + backgroundColor = "#ffffff".unsafeCast() + } + + div { + id = "response-error" + + style = jso { + border = "1px solid #ffd6d6".unsafeCast() + width = "100%".unsafeCast() + color = "#f00".unsafeCast() + backgroundColor = "#fff0f0".unsafeCast() + } + + hidden = errorText == null + +(errorText ?: "No error") + } + + button { + +"Validate test suites (application/x-ndjson)" + + disabled = rawResponse !in arrayOf(null, READY, DONE) + + onClick = useNdjson( + url = "$apiUrl/a/validate", + init = init, + onCompletion = { + rawResponse = DONE + }, + onError = { response -> + errorText = "Received HTTP ${response.status} ${response.statusText} from the server" + } + ) { validationResult -> + rawResponse = "Reading server response..." + this@VFC += Json.decodeFromString(validationResult) + }.asMouseEventHandler() + } + + button { + +"Validate test suites (text/event-stream)" + + disabled = rawResponse !in arrayOf(null, READY, DONE) + + onClick = useEventStream( + url = "$apiUrl/a/validate", + init = { init() }, + onCompletion = { + rawResponse = DONE + }, + onError = { error, readyState -> + errorText = "EventSource error (readyState = $readyState): ${JSON.stringify(error)}" + }, + ) { validationResult -> + rawResponse = "Reading server response..." + this@VFC += Json.decodeFromString(validationResult.data.toString()) + }.asMouseEventHandler() + } + + button { + +"Clear" + + onClick = useDeferredEffect { + errorText = null + rawResponse = null + clearResults() + }.asMouseEventHandler() + } + + pre { + id = "raw-response" + + +(rawResponse ?: READY) + } + + testSuiteValidationResultView { + this.validationResults = validationResults.values + } + } +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/routing/BasicRouting.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/routing/BasicRouting.kt index 3c94839003..249773fb6d 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/routing/BasicRouting.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/routing/BasicRouting.kt @@ -120,6 +120,7 @@ val basicRouting: FC = FC { props -> Routes { listOf( WelcomeView::class.react.create { userInfo = props.userInfo } to "/", + testSuiteValidationView.create() to "/a", SandboxView::class.react.create() to "/$SANDBOX", AboutUsView::class.react.create() to "/$ABOUT_US", CreationView::class.react.create() to "/$CREATE_PROJECT", From 30af688ec9723d1c4425ab370acbe7b3831ddd99 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 3 Mar 2023 13:37:15 +0000 Subject: [PATCH 2/2] Update backend-api-docs.json --- save-backend/backend-api-docs.json | 77 ++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/save-backend/backend-api-docs.json b/save-backend/backend-api-docs.json index cbdb5b8646..4c1b57925d 100644 --- a/save-backend/backend-api-docs.json +++ b/save-backend/backend-api-docs.json @@ -11,6 +11,63 @@ } ], "paths": { + "/api/v1/a/validate": { + "get": { + "operationId": "sequential", + "parameters": [ + { + "in": "header", + "name": "Accept", + "schema": { + "type": "string", + "enum": [ + "application/x-ndjson" + ] + } + } + ], + "responses": { + "401": { + "content": { + "application/x-ndjson": { + "schema": { + "$ref": "#/components/schemas/TestSuiteValidationResult" + } + }, + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/TestSuiteValidationResult" + } + } + }, + "description": "Unauthorized" + }, + "406": { + "content": { + "application/x-ndjson": { + "schema": { + "$ref": "#/components/schemas/TestSuiteValidationResult" + } + }, + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/TestSuiteValidationResult" + } + } + }, + "description": "Could not find acceptable representation." + } + }, + "security": [ + { + "basic": [] + } + ], + "tags": [ + "test-suite-validation-controller" + ] + } + }, "/api/v1/avatar/upload": { "post": { "description": "Upload an avatar for user or organization.", @@ -9641,6 +9698,26 @@ } } }, + "TestSuiteValidationResult": { + "required": [ + "checkId", + "checkName", + "percentage" + ], + "type": "object", + "properties": { + "checkId": { + "type": "string" + }, + "checkName": { + "type": "string" + }, + "percentage": { + "type": "integer", + "format": "int32" + } + } + }, "TestSuiteVersioned": { "required": [ "description",