Skip to content

Commit a5e5b9c

Browse files
WIP
1 parent 9b96941 commit a5e5b9c

File tree

6 files changed

+363
-1
lines changed

6 files changed

+363
-1
lines changed

save-backend/src/main/kotlin/com/saveourtool/save/backend/SaveApplication.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ import org.springframework.boot.autoconfigure.SpringBootApplication
66
import org.springframework.boot.context.properties.EnableConfigurationProperties
77
import org.springframework.http.ResponseEntity
88
import reactor.core.publisher.Flux
9+
import reactor.core.publisher.ParallelFlux
910
import java.nio.ByteBuffer
1011

11-
typealias ByteBufferFluxResponse = ResponseEntity<Flux<ByteBuffer>>
12+
internal typealias FluxResponse<T> = ResponseEntity<Flux<T>>
13+
internal typealias ParallelFluxResponse<T> = ResponseEntity<ParallelFlux<T>>
14+
internal typealias ByteBufferFluxResponse = FluxResponse<ByteBuffer>
1215

1316
/**
1417
* An entrypoint for spring for save-backend
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package com.saveourtool.save.backend.controllers
2+
3+
import com.saveourtool.save.backend.ParallelFluxResponse
4+
import com.saveourtool.save.backend.utils.withHttpHeaders
5+
import com.saveourtool.save.configs.ApiSwaggerSupport
6+
import com.saveourtool.save.test.TestSuiteValidationResult
7+
import com.saveourtool.save.v1
8+
import io.swagger.v3.oas.annotations.responses.ApiResponse
9+
import org.springframework.http.HttpHeaders.ACCEPT
10+
import org.springframework.http.MediaType.APPLICATION_NDJSON_VALUE
11+
import org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE
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(): ParallelFluxResponse<TestSuiteValidationResult> =
45+
withHttpHeaders {
46+
overallProgress()
47+
}
48+
49+
@Suppress("MAGIC_NUMBER")
50+
private fun singleCheck(
51+
checkId: String,
52+
checkName: String,
53+
duration: Duration,
54+
): Flux<TestSuiteValidationResult> {
55+
@Suppress("MagicNumber")
56+
val ticks = 0..100
57+
58+
val delayMillis = duration.inWholeMilliseconds / (ticks.count() - 1)
59+
60+
return Flux.fromStream(ticks.asSequence().asStream())
61+
.map { percentage ->
62+
TestSuiteValidationResult(
63+
checkId = checkId,
64+
checkName = checkName,
65+
percentage = percentage,
66+
)
67+
}
68+
.map { item ->
69+
Thread.sleep(delayMillis)
70+
item
71+
}
72+
.subscribeOn(Schedulers.boundedElastic())
73+
}
74+
75+
@Suppress("MAGIC_NUMBER")
76+
private fun overallProgress(): ParallelFlux<TestSuiteValidationResult> {
77+
@Suppress("ReactiveStreamsUnusedPublisher")
78+
val checks = arrayOf(
79+
singleCheck(
80+
"check A",
81+
"Searching for plug-ins with zero tests",
82+
10.seconds,
83+
),
84+
85+
singleCheck(
86+
"check B",
87+
"Searching for test suites with wildcard mode",
88+
20.seconds,
89+
),
90+
91+
singleCheck(
92+
"check C",
93+
"Ordering pizza from the nearest restaurant",
94+
30.seconds,
95+
),
96+
)
97+
98+
return when {
99+
checks.isEmpty() -> Flux.empty<TestSuiteValidationResult>().parallel()
100+
101+
else -> checks.reduce { left, right ->
102+
left.mergeWith(right)
103+
}
104+
.parallel(Runtime.getRuntime().availableProcessors())
105+
.runOn(Schedulers.parallel())
106+
}
107+
}
108+
}
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: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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+
@Suppress(
19+
"MagicNumber",
20+
"MAGIC_NUMBER",
21+
)
22+
val testSuiteValidationResultView: FC<TestSuiteValidationResultProps> = FC { props ->
23+
props.validationResults.forEach { item ->
24+
div {
25+
div {
26+
className = ClassName("progress progress-sm mr-2")
27+
div {
28+
className = ClassName("progress-bar bg-info")
29+
role = "progressbar".unsafeCast<AriaRole>()
30+
style = jso {
31+
width = "${item.percentage}%".unsafeCast<Width>()
32+
}
33+
ariaValueMin = 0.0
34+
ariaValueNow = item.percentage.toDouble()
35+
ariaValueMax = 100.0
36+
}
37+
}
38+
div {
39+
style = jso {
40+
whiteSpace = "pre".unsafeCast<WhiteSpace>()
41+
}
42+
43+
+item.toString()
44+
}
45+
}
46+
}
47+
}
48+
49+
/**
50+
* Properties for [testSuiteValidationResultView].
51+
*
52+
* @see testSuiteValidationResultView
53+
*/
54+
external interface TestSuiteValidationResultProps : Props {
55+
/**
56+
* Test suite validation results.
57+
*/
58+
var validationResults: Collection<TestSuiteValidationResult>
59+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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+
* @param value the validation result to add to the state of this component.
46+
*/
47+
operator fun ChildrenBuilder.plusAssign(
48+
value: TestSuiteValidationResult
49+
) {
50+
/*
51+
* When adding items to a container, prefer a lambda form of `StateSetter.invoke()`.
52+
*/
53+
setValidationResults { oldValidationResults ->
54+
/*
55+
* Preserve the order of keys in the map.
56+
*/
57+
linkedMapOf<String, TestSuiteValidationResult>().apply {
58+
putAll(oldValidationResults)
59+
this[value.checkId] = value
60+
}
61+
}
62+
}
63+
64+
/**
65+
* Clears the validation results.
66+
*
67+
* @return [Unit]
68+
*/
69+
fun clearResults() =
70+
setValidationResults(emptyMap())
71+
72+
val init = {
73+
errorText = null
74+
rawResponse = "Awaiting server response..."
75+
clearResults()
76+
}
77+
78+
div {
79+
id = "test-suite-validation-status"
80+
81+
style = jso {
82+
border = "1px solid f0f0f0".unsafeCast<Border>()
83+
width = "100%".unsafeCast<Width>()
84+
height = "100%".unsafeCast<Height>()
85+
minHeight = "600px".unsafeCast<MinHeight>()
86+
backgroundColor = "#ffffff".unsafeCast<BackgroundColor>()
87+
}
88+
89+
div {
90+
id = "response-error"
91+
92+
style = jso {
93+
border = "1px solid #ffd6d6".unsafeCast<Border>()
94+
width = "100%".unsafeCast<Width>()
95+
color = "#f00".unsafeCast<ColorProperty>()
96+
backgroundColor = "#fff0f0".unsafeCast<BackgroundColor>()
97+
}
98+
99+
hidden = errorText == null
100+
+(errorText ?: "No error")
101+
}
102+
103+
button {
104+
+"Validate test suites (application/x-ndjson)"
105+
106+
disabled = rawResponse !in arrayOf(null, READY, DONE)
107+
108+
onClick = useNdjson(
109+
url = "$apiUrl/a/validate",
110+
init = init,
111+
onCompletion = {
112+
rawResponse = DONE
113+
},
114+
onError = { response ->
115+
errorText = "Received HTTP ${response.status} ${response.statusText} from the server"
116+
}
117+
) { validationResult ->
118+
rawResponse = "Reading server response..."
119+
this@VFC += Json.decodeFromString<TestSuiteValidationResult>(validationResult)
120+
}.asMouseEventHandler()
121+
}
122+
123+
button {
124+
+"Validate test suites (text/event-stream)"
125+
126+
disabled = rawResponse !in arrayOf(null, READY, DONE)
127+
128+
onClick = useEventStream(
129+
url = "$apiUrl/a/validate",
130+
init = { init() },
131+
onCompletion = {
132+
rawResponse = DONE
133+
},
134+
onError = { error, readyState ->
135+
errorText = "EventSource error (readyState = $readyState): ${JSON.stringify(error)}"
136+
},
137+
) { validationResult ->
138+
rawResponse = "Reading server response..."
139+
this@VFC += Json.decodeFromString<TestSuiteValidationResult>(validationResult.data.toString())
140+
}.asMouseEventHandler()
141+
}
142+
143+
button {
144+
+"Clear"
145+
146+
onClick = useDeferredEffect {
147+
errorText = null
148+
rawResponse = null
149+
clearResults()
150+
}.asMouseEventHandler()
151+
}
152+
153+
pre {
154+
id = "raw-response"
155+
156+
+(rawResponse ?: READY)
157+
}
158+
159+
testSuiteValidationResultView {
160+
this.validationResults = validationResults.values
161+
}
162+
}
163+
}

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)