Skip to content

Commit 2aed6f1

Browse files
WIP
1 parent 0aa3f10 commit 2aed6f1

File tree

5 files changed

+349
-0
lines changed

5 files changed

+349
-0
lines changed
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package com.saveourtool.save.backend.controllers
2+
3+
import com.saveourtool.save.backend.utils.withHttpHeaders
4+
import com.saveourtool.save.configs.ApiSwaggerSupport
5+
import com.saveourtool.save.test.TestSuiteValidationResult
6+
import com.saveourtool.save.v1
7+
import io.swagger.v3.oas.annotations.responses.ApiResponse
8+
import org.springframework.http.HttpHeaders.ACCEPT
9+
import org.springframework.http.MediaType.APPLICATION_NDJSON_VALUE
10+
import org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE
11+
import org.springframework.http.ResponseEntity
12+
import org.springframework.web.bind.annotation.GetMapping
13+
import org.springframework.web.bind.annotation.RequestMapping
14+
import org.springframework.web.bind.annotation.RestController
15+
import reactor.core.publisher.Flux
16+
import reactor.core.publisher.ParallelFlux
17+
import reactor.core.scheduler.Schedulers
18+
import kotlin.streams.asStream
19+
import kotlin.time.Duration
20+
import kotlin.time.Duration.Companion.seconds
21+
22+
/**
23+
* Demonstrates _Server-Sent Events_ (SSE).
24+
*/
25+
@ApiSwaggerSupport
26+
@RestController
27+
@RequestMapping(path = ["/api/$v1/a"])
28+
class TestSuiteValidationController {
29+
/**
30+
* @return a stream of events.
31+
*/
32+
@GetMapping(
33+
path = ["/validate"],
34+
headers = [
35+
"$ACCEPT=$TEXT_EVENT_STREAM_VALUE",
36+
"$ACCEPT=$APPLICATION_NDJSON_VALUE",
37+
],
38+
produces = [
39+
TEXT_EVENT_STREAM_VALUE,
40+
APPLICATION_NDJSON_VALUE,
41+
],
42+
)
43+
@ApiResponse(responseCode = "406", description = "Could not find acceptable representation.")
44+
fun sequential(): ResponseEntity<ParallelFlux<TestSuiteValidationResult>> =
45+
withHttpHeaders {
46+
overallProgress()
47+
}
48+
49+
private fun singleCheck(
50+
checkId: String,
51+
checkName: String,
52+
duration: Duration,
53+
): Flux<TestSuiteValidationResult> {
54+
@Suppress("MagicNumber")
55+
val ticks = 0..100
56+
57+
val delayMillis = duration.inWholeMilliseconds / (ticks.count() - 1)
58+
59+
return Flux.fromStream(ticks.asSequence().asStream())
60+
.map { percentage ->
61+
TestSuiteValidationResult(
62+
checkId = checkId,
63+
checkName = checkName,
64+
percentage = percentage,
65+
)
66+
}
67+
.map { item ->
68+
Thread.sleep(delayMillis)
69+
item
70+
}
71+
.subscribeOn(Schedulers.boundedElastic())
72+
}
73+
74+
private fun overallProgress(): ParallelFlux<TestSuiteValidationResult> {
75+
@Suppress("ReactiveStreamsUnusedPublisher")
76+
val checks = arrayOf(
77+
singleCheck(
78+
"check A",
79+
"Searching for plug-ins with zero tests",
80+
10.seconds,
81+
),
82+
83+
singleCheck(
84+
"check B",
85+
"Searching for test suites with wildcard mode",
86+
20.seconds,
87+
),
88+
89+
singleCheck(
90+
"check C",
91+
"Ordering pizza from the nearest restaurant",
92+
30.seconds,
93+
),
94+
)
95+
96+
return when {
97+
checks.isEmpty() -> Flux.empty<TestSuiteValidationResult>().parallel()
98+
99+
else -> checks.reduce { left, right ->
100+
left.mergeWith(right)
101+
}
102+
.parallel(Runtime.getRuntime().availableProcessors())
103+
.runOn(Schedulers.parallel())
104+
}
105+
}
106+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.saveourtool.save.test
2+
3+
import kotlinx.serialization.Serializable
4+
5+
/**
6+
* @property checkId the unique check id.
7+
* @property checkName the human-readable check name.
8+
* @property percentage the completion percentage (`0..100`).
9+
*/
10+
@Serializable
11+
data class TestSuiteValidationResult(
12+
val checkId: String,
13+
val checkName: String,
14+
val percentage: Int
15+
) {
16+
init {
17+
@Suppress("MAGIC_NUMBER")
18+
require(percentage in 0..100) {
19+
percentage.toString()
20+
}
21+
}
22+
23+
override fun toString(): String =
24+
when (percentage) {
25+
100 -> "Check $checkName is complete."
26+
else -> "Check $checkName is running, $percentage% complete\u2026"
27+
}
28+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
@file:Suppress("FILE_NAME_MATCH_CLASS")
2+
3+
package com.saveourtool.save.frontend.components.views
4+
5+
import com.saveourtool.save.test.TestSuiteValidationResult
6+
import csstype.ClassName
7+
import csstype.WhiteSpace
8+
import csstype.Width
9+
import js.core.jso
10+
import react.FC
11+
import react.Props
12+
import react.dom.aria.AriaRole
13+
import react.dom.aria.ariaValueMax
14+
import react.dom.aria.ariaValueMin
15+
import react.dom.aria.ariaValueNow
16+
import react.dom.html.ReactHTML.div
17+
18+
val testSuiteValidationResultView: FC<TestSuiteValidationResultProps> = FC { props ->
19+
props.validationResults.forEach { item ->
20+
div {
21+
div {
22+
className = ClassName("progress progress-sm mr-2")
23+
div {
24+
className = ClassName("progress-bar bg-info")
25+
role = "progressbar".unsafeCast<AriaRole>()
26+
style = jso {
27+
width = "${item.percentage}%".unsafeCast<Width>()
28+
}
29+
ariaValueMin = 0.0
30+
ariaValueNow = item.percentage.toDouble()
31+
ariaValueMax = 100.0
32+
}
33+
}
34+
div {
35+
style = jso {
36+
whiteSpace = "pre".unsafeCast<WhiteSpace>()
37+
}
38+
39+
+item.toString()
40+
}
41+
}
42+
}
43+
}
44+
45+
/**
46+
* Properties for [testSuiteValidationResultView].
47+
*
48+
* @see testSuiteValidationResultView
49+
*/
50+
external interface TestSuiteValidationResultProps : Props {
51+
/**
52+
* Test suite validation results.
53+
*/
54+
var validationResults: Collection<TestSuiteValidationResult>
55+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE")
2+
3+
package com.saveourtool.save.frontend.components.views
4+
5+
import com.saveourtool.save.frontend.utils.apiUrl
6+
import com.saveourtool.save.frontend.utils.asMouseEventHandler
7+
import com.saveourtool.save.frontend.utils.useDeferredEffect
8+
import com.saveourtool.save.frontend.utils.useEventStream
9+
import com.saveourtool.save.frontend.utils.useNdjson
10+
import com.saveourtool.save.test.TestSuiteValidationResult
11+
import csstype.BackgroundColor
12+
import csstype.Border
13+
import csstype.ColorProperty
14+
import csstype.Height
15+
import csstype.MinHeight
16+
import csstype.Width
17+
import js.core.jso
18+
import react.ChildrenBuilder
19+
import react.VFC
20+
import react.dom.html.ReactHTML.button
21+
import react.dom.html.ReactHTML.div
22+
import react.dom.html.ReactHTML.pre
23+
import react.useState
24+
import kotlinx.serialization.decodeFromString
25+
import kotlinx.serialization.json.Json
26+
27+
private const val READY = "Ready."
28+
29+
private const val DONE = "Done."
30+
31+
@Suppress("LOCAL_VARIABLE_EARLY_DECLARATION")
32+
val testSuiteValidationView: VFC = VFC {
33+
var errorText by useState<String?>(initialValue = null)
34+
35+
var rawResponse by useState<String?>(initialValue = null)
36+
37+
/*
38+
* When dealing with containers, avoid using `by useState()`.
39+
*/
40+
val (validationResults, setValidationResults) = useState(initialValue = emptyMap<String, TestSuiteValidationResult>())
41+
42+
/**
43+
* Updates the validation results.
44+
*/
45+
operator fun ChildrenBuilder.plusAssign(
46+
value: TestSuiteValidationResult
47+
) {
48+
/*
49+
* When adding items to a container, prefer a lambda form of `StateSetter.invoke()`.
50+
*/
51+
setValidationResults { oldValidationResults ->
52+
/*
53+
* Preserve the order of keys in the map.
54+
*/
55+
linkedMapOf<String, TestSuiteValidationResult>().apply {
56+
putAll(oldValidationResults)
57+
this[value.checkId] = value
58+
}
59+
}
60+
}
61+
62+
/**
63+
* Clears the validation results.
64+
*/
65+
fun clearResults() =
66+
setValidationResults(emptyMap())
67+
68+
val init = {
69+
errorText = null
70+
rawResponse = "Awaiting server response..."
71+
clearResults()
72+
}
73+
74+
div {
75+
id = "test-suite-validation-status"
76+
77+
style = jso {
78+
border = "1px solid f0f0f0".unsafeCast<Border>()
79+
width = "100%".unsafeCast<Width>()
80+
height = "100%".unsafeCast<Height>()
81+
minHeight = "600px".unsafeCast<MinHeight>()
82+
backgroundColor = "#ffffff".unsafeCast<BackgroundColor>()
83+
}
84+
85+
div {
86+
id = "response-error"
87+
88+
style = jso {
89+
border = "1px solid #ffd6d6".unsafeCast<Border>()
90+
width = "100%".unsafeCast<Width>()
91+
color = "#f00".unsafeCast<ColorProperty>()
92+
backgroundColor = "#fff0f0".unsafeCast<BackgroundColor>()
93+
}
94+
95+
hidden = errorText == null
96+
+(errorText ?: "No error")
97+
}
98+
99+
button {
100+
+"Validate test suites (application/x-ndjson)"
101+
102+
disabled = rawResponse !in arrayOf(null, READY, DONE)
103+
104+
onClick = useNdjson(
105+
url = "$apiUrl/a/validate",
106+
init = init,
107+
onCompletion = {
108+
rawResponse = DONE
109+
},
110+
onError = { response ->
111+
errorText = "Received HTTP ${response.status} ${response.statusText} from the server"
112+
}
113+
) { validationResult ->
114+
rawResponse = "Reading server response..."
115+
this@VFC += Json.decodeFromString<TestSuiteValidationResult>(validationResult)
116+
}.asMouseEventHandler()
117+
}
118+
119+
button {
120+
+"Validate test suites (text/event-stream)"
121+
122+
disabled = rawResponse !in arrayOf(null, READY, DONE)
123+
124+
onClick = useEventStream(
125+
url = "$apiUrl/a/validate",
126+
init = { init() },
127+
onCompletion = {
128+
rawResponse = DONE
129+
},
130+
onError = { error, readyState ->
131+
errorText = "EventSource error (readyState = $readyState): ${JSON.stringify(error)}"
132+
},
133+
) { validationResult ->
134+
rawResponse = "Reading server response..."
135+
this@VFC += Json.decodeFromString<TestSuiteValidationResult>(validationResult.data.toString())
136+
}.asMouseEventHandler()
137+
}
138+
139+
button {
140+
+"Clear"
141+
142+
onClick = useDeferredEffect {
143+
errorText = null
144+
rawResponse = null
145+
clearResults()
146+
}.asMouseEventHandler()
147+
}
148+
149+
pre {
150+
id = "raw-response"
151+
152+
+(rawResponse ?: READY)
153+
}
154+
155+
testSuiteValidationResultView {
156+
this.validationResults = validationResults.values
157+
}
158+
}
159+
}

save-frontend/src/main/kotlin/com/saveourtool/save/frontend/routing/BasicRouting.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ val basicRouting: FC<AppProps> = FC { props ->
120120
Routes {
121121
listOf(
122122
WelcomeView::class.react.create { userInfo = props.userInfo } to "/",
123+
testSuiteValidationView.create() to "/a",
123124
SandboxView::class.react.create() to "/$SANDBOX",
124125
AboutUsView::class.react.create() to "/$ABOUT_US",
125126
CreationView::class.react.create() to "/$CREATE_PROJECT",

0 commit comments

Comments
 (0)