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..00507c007e --- /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.TestSuiteValidationProgress +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 -> + TestSuiteValidationProgress( + 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/TestSuiteValidationError.kt b/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/test/TestSuiteValidationError.kt new file mode 100644 index 0000000000..28cf1f897d --- /dev/null +++ b/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/test/TestSuiteValidationError.kt @@ -0,0 +1,18 @@ +package com.saveourtool.save.test + +import kotlinx.serialization.Serializable + +/** + * @property checkId the unique check id. + * @property checkName the human-readable check name. + * @property message the error message (w/o the trailing dot). + */ +@Serializable +data class TestSuiteValidationError( + override val checkId: String, + override val checkName: String, + val message: String, +) : TestSuiteValidationResult() { + override fun toString(): String = + "$checkName: $message." +} diff --git a/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/test/TestSuiteValidationProgress.kt b/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/test/TestSuiteValidationProgress.kt new file mode 100644 index 0000000000..b5bddef09d --- /dev/null +++ b/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/test/TestSuiteValidationProgress.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 TestSuiteValidationProgress( + override val checkId: String, + override val checkName: String, + val percentage: Int +) : TestSuiteValidationResult() { + 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-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..f561ad3f37 --- /dev/null +++ b/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/test/TestSuiteValidationResult.kt @@ -0,0 +1,17 @@ +package com.saveourtool.save.test + +/** + * The validation result — either a progress message (intermediate or + * terminal) or an error message (terminal). + */ +sealed class TestSuiteValidationResult { + /** + * The unique check id. + */ + abstract val checkId: String + + /** + * The human-readable check name. + */ + abstract val checkName: String +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/testsuitessources/fetch/TestSuitesSourceFetcher.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/testsuitessources/fetch/TestSuitesSourceFetcher.kt index 880e12962e..6160fdce50 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/testsuitessources/fetch/TestSuitesSourceFetcher.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/testsuitessources/fetch/TestSuitesSourceFetcher.kt @@ -106,7 +106,7 @@ fun ChildrenBuilder.testSuitesSourceFetcher( button { type = ButtonType.button className = ClassName("btn btn-primary mt-4") - +"Fetch" + +"Fetch" // XXX Should be disabled by default onClick = { triggerFetchTestSuiteSource() windowOpenness.closeWindow() 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..76b511ba97 --- /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.TestSuiteValidationProgress +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..6f4a5b5f77 --- /dev/null +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/TestSuiteValidationView.kt @@ -0,0 +1,165 @@ +@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.TestSuiteValidationProgress +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: TestSuiteValidationProgress + ) { + /* + * 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) + title = "Foo" + + 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) + title = "Bar" + + 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", diff --git a/save-frontend/webpack.config.d/dev-server.js b/save-frontend/webpack.config.d/dev-server.js index 72de12b456..2dc7314c1d 100644 --- a/save-frontend/webpack.config.d/dev-server.js +++ b/save-frontend/webpack.config.d/dev-server.js @@ -21,11 +21,6 @@ config.devServer = Object.assign( proxyReq.setHeader("X-Authorization-Source", "basic"); } }, - { - context: ["/demo/api/**"], - target: 'http://localhost:5421', - logLevel: 'debug', - }, { context: ["/cpg/api/**"], target: 'http://localhost:5500', diff --git a/save-preprocessor/build.gradle.kts b/save-preprocessor/build.gradle.kts index 15b138b38d..0cb25aec70 100644 --- a/save-preprocessor/build.gradle.kts +++ b/save-preprocessor/build.gradle.kts @@ -28,4 +28,5 @@ dependencies { implementation(libs.ktoml.core) implementation(libs.kotlinx.serialization.json) implementation(libs.commons.compress) + implementation(libs.arrow.kt.core) } diff --git a/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/common/CloneAndProcessDirectoryAction.kt b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/common/CloneAndProcessDirectoryAction.kt new file mode 100644 index 0000000000..47e58be08b --- /dev/null +++ b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/common/CloneAndProcessDirectoryAction.kt @@ -0,0 +1,25 @@ +package com.saveourtool.save.preprocessor.common + +import com.saveourtool.save.entities.GitDto +import com.saveourtool.save.preprocessor.service.GitPreprocessorService +import org.reactivestreams.Publisher + +/** + * Uses [GitPreprocessorService] to clone and process a repository. + */ +fun interface CloneAndProcessDirectoryAction> { + /** + * Clones and processes a repository identified by [gitDto]. + * + * @param gitDto the _Git_ URL along with optional credentials. + * @param branchOrTagOrCommit either a branch name, or a tag name, or a + * commit hash. + * @param repositoryProcessor the custom process action. + * @return a custom [Publisher] returned by [repositoryProcessor]. + */ + fun GitPreprocessorService.cloneAndProcessDirectoryAsync( + gitDto: GitDto, + branchOrTagOrCommit: String, + repositoryProcessor: GitRepositoryProcessor, + ): T +} diff --git a/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/common/CloneResult.kt b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/common/CloneResult.kt new file mode 100644 index 0000000000..726b298359 --- /dev/null +++ b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/common/CloneResult.kt @@ -0,0 +1,15 @@ +package com.saveourtool.save.preprocessor.common + +import com.saveourtool.save.preprocessor.utils.GitCommitInfo +import java.nio.file.Path + +/** + * The result of running `git clone`. + * + * @property directory the local directory the repository has been cloned into. + * @property gitCommitInfo the _Git_ commit metadata. + */ +data class CloneResult( + val directory: Path, + val gitCommitInfo: GitCommitInfo, +) diff --git a/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/common/GitRepositoryProcessor.kt b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/common/GitRepositoryProcessor.kt new file mode 100644 index 0000000000..e4c75babb3 --- /dev/null +++ b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/common/GitRepositoryProcessor.kt @@ -0,0 +1,16 @@ +package com.saveourtool.save.preprocessor.common + +import org.reactivestreams.Publisher + +/** + * Asynchronously processes a local _Git_ repository. + */ +fun interface GitRepositoryProcessor> { + /** + * Processes the cloned _Git_ repository, returning a `Mono` or a `Flux`. + * + * @param cloneResult the local directory along with _Git_ metadata. + * @return the result of the processing as a custom [Publisher]. + */ + fun processAsync(cloneResult: CloneResult): T +} diff --git a/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/controllers/AwesomeBenchmarksDownloadController.kt b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/controllers/AwesomeBenchmarksDownloadController.kt index 85286cbf81..f64153d4b1 100644 --- a/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/controllers/AwesomeBenchmarksDownloadController.kt +++ b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/controllers/AwesomeBenchmarksDownloadController.kt @@ -58,7 +58,7 @@ class AwesomeBenchmarksDownloadController( gitPreprocessorService.cloneBranchAndProcessDirectory( gitDto, branch - ) { repositoryDir: Path, _ -> + ) { (repositoryDir: Path) -> log.info("Awesome-benchmarks were downloaded to ${repositoryDir.absolutePathString()}") processDirectoryAndCleanUp(repositoryDir) } diff --git a/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/controllers/TestSuitesPreprocessorController.kt b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/controllers/TestSuitesPreprocessorController.kt index 8494250977..4904617707 100644 --- a/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/controllers/TestSuitesPreprocessorController.kt +++ b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/controllers/TestSuitesPreprocessorController.kt @@ -1,12 +1,14 @@ package com.saveourtool.save.preprocessor.controllers import com.saveourtool.save.entities.GitDto +import com.saveourtool.save.preprocessor.common.CloneAndProcessDirectoryAction +import com.saveourtool.save.preprocessor.common.GitRepositoryProcessor import com.saveourtool.save.preprocessor.service.GitPreprocessorService -import com.saveourtool.save.preprocessor.service.GitRepositoryProcessor import com.saveourtool.save.preprocessor.service.TestDiscoveringService import com.saveourtool.save.preprocessor.service.TestsPreprocessorToBackendBridge import com.saveourtool.save.preprocessor.utils.GitCommitInfo import com.saveourtool.save.request.TestsSourceFetchRequest +import com.saveourtool.save.test.TestSuiteValidationResult import com.saveourtool.save.test.TestsSourceSnapshotDto import com.saveourtool.save.testsuite.TestSuitesSourceFetchMode import com.saveourtool.save.utils.* @@ -18,6 +20,7 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Flux import reactor.core.publisher.Mono import reactor.kotlin.core.publisher.switchIfEmpty import reactor.kotlin.core.util.function.component1 @@ -27,8 +30,6 @@ import java.nio.file.Path import kotlin.io.path.div -typealias CloneAndProcessDirectoryAction = GitPreprocessorService.(GitDto, String, GitRepositoryProcessor) -> Mono - /** * Preprocessor's controller for [com.saveourtool.save.entities.TestSuitesSource] */ @@ -48,41 +49,50 @@ class TestSuitesPreprocessorController( @PostMapping("/fetch") fun fetch( @RequestBody request: TestsSourceFetchRequest, - ): Mono = fetchTestSuites( - request = request, - cloneAndProcessDirectoryAction = when (request.mode) { - TestSuitesSourceFetchMode.BY_BRANCH -> GitPreprocessorService::cloneBranchAndProcessDirectory - TestSuitesSourceFetchMode.BY_COMMIT -> GitPreprocessorService::cloneCommitAndProcessDirectory - TestSuitesSourceFetchMode.BY_TAG -> GitPreprocessorService::cloneTagAndProcessDirectory - } - ) + ): Flux { + @Suppress("TYPE_ALIAS") + val cloneAndProcessDirectoryAction: GitPreprocessorService.(GitDto, String, GitRepositoryProcessor>) -> Flux = + when (request.mode) { + TestSuitesSourceFetchMode.BY_BRANCH -> GitPreprocessorService::cloneBranchAndProcessDirectoryMany + TestSuitesSourceFetchMode.BY_COMMIT -> GitPreprocessorService::cloneCommitAndProcessDirectoryMany + TestSuitesSourceFetchMode.BY_TAG -> GitPreprocessorService::cloneTagAndProcessDirectoryMany + } + + return fetchTestSuites( + request = request, + cloneAndProcessDirectoryAction = cloneAndProcessDirectoryAction + ) + } @NonBlocking + @Suppress("TYPE_ALIAS") private fun fetchTestSuites( request: TestsSourceFetchRequest, - cloneAndProcessDirectoryAction: CloneAndProcessDirectoryAction, - ): Mono = gitPreprocessorService.cloneAndProcessDirectoryAction( - request.source.gitDto, - request.version - ) { repositoryDirectory, gitCommitInfo -> - testsPreprocessorToBackendBridge.findTestsSourceSnapshot(request.source.requiredId(), gitCommitInfo.id) - .switchIfEmpty { - doFetchTests(repositoryDirectory, gitCommitInfo, request) - } - .flatMap { snapshot -> - testsPreprocessorToBackendBridge.saveTestsSourceVersion(request.createVersion(snapshot)) - }.doOnNext { isSaved: Boolean -> - log.info { - val messagePrefix = "Tests from ${request.source.gitDto.url}" - val status = when { - isSaved -> "saved" - else -> "not saved: the snapshot already exists" - } - val messageSuffix = "(version \"${request.version}\"; commit ${gitCommitInfo.id})." - - "$messagePrefix $status $messageSuffix" + cloneAndProcessDirectoryAction: CloneAndProcessDirectoryAction>, + ): Flux = with(cloneAndProcessDirectoryAction) { + gitPreprocessorService.cloneAndProcessDirectoryAsync( + request.source.gitDto, + request.version + ) { (repositoryDirectory, gitCommitInfo) -> + testsPreprocessorToBackendBridge.findTestsSourceSnapshot(request.source.requiredId(), gitCommitInfo.id) + .switchIfEmpty { + doFetchTests(repositoryDirectory, gitCommitInfo, request) } - }.thenReturn(Unit) + .flatMap { snapshot -> + testsPreprocessorToBackendBridge.saveTestsSourceVersion(request.createVersion(snapshot)) + }.doOnNext { isSaved: Boolean -> + log.info { + val messagePrefix = "Tests from ${request.source.gitDto.url}" + val status = when { + isSaved -> "saved" + else -> "not saved: the snapshot already exists" + } + val messageSuffix = "(version \"${request.version}\"; commit ${gitCommitInfo.id})." + + "$messagePrefix $status $messageSuffix" + } + }.thenMany(Flux.empty()) + } } @NonBlocking diff --git a/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/service/GitPreprocessorService.kt b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/service/GitPreprocessorService.kt index ac4c57f4bb..2605932d52 100644 --- a/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/service/GitPreprocessorService.kt +++ b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/service/GitPreprocessorService.kt @@ -1,6 +1,8 @@ package com.saveourtool.save.preprocessor.service import com.saveourtool.save.entities.GitDto +import com.saveourtool.save.preprocessor.common.CloneResult +import com.saveourtool.save.preprocessor.common.GitRepositoryProcessor import com.saveourtool.save.preprocessor.config.ConfigProperties import com.saveourtool.save.preprocessor.utils.GitCommitInfo import com.saveourtool.save.preprocessor.utils.cloneBranchToDirectory @@ -11,6 +13,7 @@ import org.eclipse.jgit.util.FileUtils import org.jetbrains.annotations.NonBlocking import org.slf4j.Logger import org.springframework.stereotype.Service +import reactor.core.publisher.Flux import reactor.core.publisher.Mono import java.io.IOException import java.nio.file.Files @@ -19,8 +22,6 @@ import java.nio.file.Paths import kotlin.io.path.absolutePathString import kotlin.io.path.createDirectories -typealias CloneResult = Pair -typealias GitRepositoryProcessor = (Path, GitCommitInfo) -> Mono typealias ArchiveProcessor = (Path) -> Mono /** @@ -45,81 +46,189 @@ class GitPreprocessorService( ) /** - * @param gitDto - * @param tagName - * @param repositoryProcessor operation on folder should be finished here -- folder will be removed after it - * @return result of [repositoryProcessor] + * @param gitDto the Git URL with optional credentials. + * @param tagName the Git tag. + * @param repositoryProcessor folder processing should be finished here + * — the folder will be removed afterwards. + * @return the result returned by [GitRepositoryProcessor.processAsync]. * @throws IllegalStateException * @throws Exception + * @see cloneTagAndProcessDirectoryMany + * @see cloneBranchAndProcessDirectory + * @see cloneBranchAndProcessDirectoryMany + * @see cloneCommitAndProcessDirectory + * @see cloneCommitAndProcessDirectoryMany */ - fun cloneTagAndProcessDirectory( + @Suppress("TYPE_ALIAS") + fun cloneTagAndProcessDirectory( gitDto: GitDto, tagName: String, - repositoryProcessor: GitRepositoryProcessor, + repositoryProcessor: GitRepositoryProcessor>, ): Mono = doCloneAndProcessDirectory(gitDto, repositoryProcessor) { cloneTagToDirectory(tagName, it) } /** - * @param gitDto - * @param branchName - * @param repositoryProcessor operation on folder should be finished here -- folder will be removed after it - * @return result of [repositoryProcessor] + * @param gitDto the Git URL with optional credentials. + * @param branchName the Git branch. + * @param repositoryProcessor folder processing should be finished here + * — the folder will be removed afterwards. + * @return the result returned by [GitRepositoryProcessor.processAsync]. * @throws IllegalStateException * @throws Exception + * @see cloneTagAndProcessDirectory + * @see cloneTagAndProcessDirectoryMany + * @see cloneBranchAndProcessDirectoryMany + * @see cloneCommitAndProcessDirectory + * @see cloneCommitAndProcessDirectoryMany */ - fun cloneBranchAndProcessDirectory( + @Suppress("TYPE_ALIAS") + fun cloneBranchAndProcessDirectory( gitDto: GitDto, branchName: String, - repositoryProcessor: GitRepositoryProcessor, + repositoryProcessor: GitRepositoryProcessor>, ): Mono = doCloneAndProcessDirectory(gitDto, repositoryProcessor) { cloneBranchToDirectory(branchName, it) } /** - * @param gitDto - * @param commitId - * @param repositoryProcessor operation on folder should be finished here -- folder will be removed after it - * @return result of [repositoryProcessor] + * @param gitDto the Git URL with optional credentials. + * @param commitId the Git commit hash. + * @param repositoryProcessor folder processing should be finished here + * — the folder will be removed afterwards. + * @return the result returned by [GitRepositoryProcessor.processAsync]. * @throws IllegalStateException * @throws Exception + * @see cloneTagAndProcessDirectory + * @see cloneTagAndProcessDirectoryMany + * @see cloneBranchAndProcessDirectory + * @see cloneBranchAndProcessDirectoryMany + * @see cloneCommitAndProcessDirectoryMany */ - fun cloneCommitAndProcessDirectory( + @Suppress("TYPE_ALIAS") + fun cloneCommitAndProcessDirectory( gitDto: GitDto, commitId: String, - repositoryProcessor: GitRepositoryProcessor, + repositoryProcessor: GitRepositoryProcessor>, ): Mono = doCloneAndProcessDirectory(gitDto, repositoryProcessor) { cloneCommitToDirectory(commitId, it) } + /** + * @param gitDto the Git URL with optional credentials. + * @param tagName the Git tag. + * @param repositoryProcessor folder processing should be finished here + * — the folder will be removed afterwards. + * @return the result returned by [GitRepositoryProcessor.processAsync]. + * @see cloneTagAndProcessDirectory + * @see cloneBranchAndProcessDirectory + * @see cloneBranchAndProcessDirectoryMany + * @see cloneCommitAndProcessDirectory + * @see cloneCommitAndProcessDirectoryMany + */ + @Suppress("TYPE_ALIAS") + fun cloneTagAndProcessDirectoryMany( + gitDto: GitDto, + tagName: String, + repositoryProcessor: GitRepositoryProcessor>, + ): Flux = doCloneAndProcessDirectoryMany(gitDto, repositoryProcessor) { + cloneTagToDirectory(tagName, it) + } + + /** + * @param gitDto the Git URL with optional credentials. + * @param branchName the Git branch. + * @param repositoryProcessor folder processing should be finished here + * — the folder will be removed afterwards. + * @return the result returned by [GitRepositoryProcessor.processAsync]. + * @see cloneTagAndProcessDirectory + * @see cloneTagAndProcessDirectoryMany + * @see cloneBranchAndProcessDirectory + * @see cloneCommitAndProcessDirectory + * @see cloneCommitAndProcessDirectoryMany + */ + @Suppress("TYPE_ALIAS") + fun cloneBranchAndProcessDirectoryMany( + gitDto: GitDto, + branchName: String, + repositoryProcessor: GitRepositoryProcessor>, + ): Flux = doCloneAndProcessDirectoryMany(gitDto, repositoryProcessor) { + cloneBranchToDirectory(branchName, it) + } + + /** + * @param gitDto + * @param commitId the Git commit hash. + * @param repositoryProcessor folder processing should be finished here + * — the folder will be removed afterwards. + * @return result of [GitRepositoryProcessor.processAsync] + * @see cloneTagAndProcessDirectory + * @see cloneTagAndProcessDirectoryMany + * @see cloneBranchAndProcessDirectory + * @see cloneBranchAndProcessDirectoryMany + * @see cloneCommitAndProcessDirectory + */ + @Suppress("TYPE_ALIAS") + fun cloneCommitAndProcessDirectoryMany( + gitDto: GitDto, + commitId: String, + repositoryProcessor: GitRepositoryProcessor>, + ): Flux = doCloneAndProcessDirectoryMany(gitDto, repositoryProcessor) { + cloneCommitToDirectory(commitId, it) + } + + @Suppress("TooGenericExceptionCaught") + @NonBlocking + private fun GitDto.cloneAsync(doCloneToDirectory: GitDto.(Path) -> GitCommitInfo): Mono = + blockingToMono { + val tmpDir = createTempDirectoryForRepository() + val gitCommitInfo = try { + doCloneToDirectory(tmpDir) + } catch (ex: Exception) { + log.error(ex) { "Failed to clone git repository $url" } + tmpDir.deleteRecursivelySafely() + throw ex + } + CloneResult(tmpDir, gitCommitInfo) + } + + @NonBlocking + private fun CloneResult.cleanupAsync(): Mono = + directory.deleteRecursivelySafelyAsync() + /** * @param doCloneToDirectory a blocking `git-clone` action (will be wrapped - * with [blockingToMono]). + * with [blockingToMono]). + * @see doCloneAndProcessDirectoryMany */ @NonBlocking - @Suppress("TooGenericExceptionCaught") - private fun doCloneAndProcessDirectory( + @Suppress("TYPE_ALIAS") + private fun doCloneAndProcessDirectory( gitDto: GitDto, - repositoryProcessor: GitRepositoryProcessor, + repositoryProcessor: GitRepositoryProcessor>, doCloneToDirectory: GitDto.(Path) -> GitCommitInfo, - ): Mono { - val cloneAction: () -> CloneResult = { - val tmpDir = createTempDirectoryForRepository() - val gitCommitInfo = try { - gitDto.doCloneToDirectory(tmpDir) - } catch (ex: Exception) { - log.error(ex) { "Failed to clone git repository ${gitDto.url}" } - tmpDir.deleteRecursivelySafely() - throw ex - } - tmpDir to gitCommitInfo - } - return Mono.usingWhen( - blockingToMono(cloneAction), - { (directory, gitCommitInfo) -> repositoryProcessor(directory, gitCommitInfo) }, - { (directory, _) -> directory.deleteRecursivelySafelyAsync() } - ) - } + ): Mono = + Mono.usingWhen( + gitDto.cloneAsync(doCloneToDirectory), + repositoryProcessor::processAsync, + ) { cloneResult -> cloneResult.cleanupAsync() } + + /** + * @param doCloneToDirectory a blocking `git-clone` action (will be wrapped + * with [blockingToMono]). + * @see doCloneAndProcessDirectory + */ + @NonBlocking + @Suppress("TYPE_ALIAS") + private fun doCloneAndProcessDirectoryMany( + gitDto: GitDto, + repositoryProcessor: GitRepositoryProcessor>, + doCloneToDirectory: GitDto.(Path) -> GitCommitInfo, + ): Flux = + Flux.usingWhen( + gitDto.cloneAsync(doCloneToDirectory), + repositoryProcessor::processAsync, + ) { cloneResult -> cloneResult.cleanupAsync() } /** * @param pathToRepository diff --git a/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/service/TestDiscoveringService.kt b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/service/TestDiscoveringService.kt index e9dcec8233..c58b431a7e 100644 --- a/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/service/TestDiscoveringService.kt +++ b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/service/TestDiscoveringService.kt @@ -3,13 +3,17 @@ package com.saveourtool.save.preprocessor.service import com.saveourtool.save.core.config.TestConfig +import com.saveourtool.save.core.config.TestConfigSections import com.saveourtool.save.core.files.ConfigDetector import com.saveourtool.save.core.plugin.GeneralConfig +import com.saveourtool.save.core.plugin.PluginConfig import com.saveourtool.save.core.plugin.PluginException import com.saveourtool.save.core.utils.buildActivePlugins import com.saveourtool.save.core.utils.processInPlace import com.saveourtool.save.entities.TestSuite +import com.saveourtool.save.plugin.warn.WarnPluginConfig import com.saveourtool.save.plugins.fix.FixPlugin +import com.saveourtool.save.plugins.fixandwarn.FixAndWarnPluginConfig import com.saveourtool.save.preprocessor.utils.toHash import com.saveourtool.save.test.TestDto import com.saveourtool.save.test.TestsSourceSnapshotDto @@ -18,10 +22,14 @@ import com.saveourtool.save.testsuite.TestSuiteDto import com.saveourtool.save.utils.EmptyResponse import com.saveourtool.save.utils.blockingToMono import com.saveourtool.save.utils.debug +import com.saveourtool.save.utils.error import com.saveourtool.save.utils.info import com.saveourtool.save.utils.requireIsAbsolute import com.saveourtool.save.utils.thenJust - +import arrow.core.Either +import arrow.core.getOrElse +import arrow.core.left +import arrow.core.right import okio.FileSystem import okio.Path import okio.Path.Companion.toOkioPath @@ -35,9 +43,14 @@ import reactor.kotlin.core.publisher.toFlux import reactor.kotlin.core.publisher.toMono import reactor.kotlin.core.util.function.component1 import reactor.kotlin.core.util.function.component2 +import java.util.regex.PatternSyntaxException import kotlin.io.path.absolute import kotlin.io.path.div - +import kotlin.io.path.isDirectory +import kotlin.io.path.isRegularFile +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name +import kotlin.io.path.relativeToOrNull import java.nio.file.Path as NioPath /** @@ -46,6 +59,7 @@ import java.nio.file.Path as NioPath @Service class TestDiscoveringService( private val testsPreprocessorToBackendBridge: TestsPreprocessorToBackendBridge, + private val validationService: TestSuiteValidationService, ) { /** * @param repositoryPath @@ -59,16 +73,22 @@ class TestDiscoveringService( sourceSnapshot: TestsSourceSnapshotDto, ): Mono> { log.info { "Starting to save new test suites for root test config in $repositoryPath" } - return blockingToMono { + val rootTestConfigAsync = blockingToMono { getRootTestConfig((repositoryPath / testRootPath).absolute().normalize()) } + + return rootTestConfigAsync .zipWhen { rootTestConfig -> { log.info { "Starting to discover test suites for root test config ${rootTestConfig.location}" } - discoverAllTestSuites( + val testSuites: List = discoverAllTestSuites( rootTestConfig, sourceSnapshot, ) + testSuites.forEach { testSuite -> + log.info { "XXX " } + } + testSuites }.toMono() } .flatMap { (rootTestConfig, testSuites) -> @@ -105,28 +125,138 @@ class TestDiscoveringService( * @throws IllegalArgumentException when provided path doesn't point to a valid config file */ @NonBlocking - @Suppress("UnsafeCallOnNullableType") + @Suppress( + "UnsafeCallOnNullableType", + "LongMethod", + "MagicNumber", + "RedundantHigherOrderMapUsage", + "VARIABLE_NAME_INCORRECT", + "TOO_LONG_FUNCTION", + "WRONG_NEWLINES", + ) fun getAllTestSuites( rootTestConfig: TestConfig, sourceSnapshot: TestsSourceSnapshotDto, - ) = rootTestConfig - .getAllTestConfigs() - .asSequence() - .mapNotNull { it.getGeneralConfigOrNull() } - .filterNot { it.suiteName == null } - .filterNot { it.description == null } - .map { config -> - // we operate here with suite names from only those TestConfigs, that have General section with suiteName key - TestSuiteDto( - config.suiteName!!, - config.description, - sourceSnapshot, - config.language, - config.tags - ) + ): List { + log.info { "XXX getAllTestSuites()" } + val t0 = System.nanoTime() + val allTestConfigs = rootTestConfig + .getAllTestConfigs() + val t1 = System.nanoTime() + @Suppress("FLOAT_IN_ACCURATE_CALCULATIONS") + log.info { "XXX getAllTestConfigs() took ${(t1 - t0) / 1000L / 1e3} ms" } + + return allTestConfigs + .asSequence() + .map { testConfig: TestConfig -> // XXX Replace with onEach or forEach? + log.info { "XXX Test config: $testConfig" } + val pluginConfigs = testConfig.pluginConfigs + .asSequence() + .filterNot { config -> + config is GeneralConfig + } + + @Suppress("GENERIC_VARIABLE_WRONG_DECLARATION") + val errors = mutableListOf() + + @Suppress("TYPE_ALIAS") + val resources: MutableMap> = pluginConfigs.fold(mutableMapOf()) { acc, config -> + acc.apply { + compute(config.type) { _, valueOrNull: MutableList? -> + val resourceNames = config.getResourceNames().getOrElse { error -> + errors += error + emptySequence() + } + + (valueOrNull ?: mutableListOf()).apply { + addAll(resourceNames) + } + } + } + } + + resources.forEach { type, resourceNames -> + log.info { "\t$type" } + resourceNames.forEach { + log.info { "\t\t$it" } + } + } + errors.forEach { error -> + log.info { "\t$error" } + } + + val warnConfigs = pluginConfigs.asSequence().mapNotNull { config -> + when (config) { + is WarnPluginConfig -> config + is FixAndWarnPluginConfig -> config.warn + else -> null + } + }.toList() + log.info { "XXX Warn configs: ${warnConfigs.size}" } + warnConfigs.forEachIndexed { index, config -> + log.info { "\t$index: wildCardInDirectoryMode = ${config.wildCardInDirectoryMode}" } // XXX Should be null + } + + testConfig + } + .mapNotNull { it.getGeneralConfigOrNull() } + .filterNot { it.suiteName == null } + .filterNot { it.description == null } + .map { generalConfig: GeneralConfig -> + log.info { "XXX General config: $generalConfig" } + generalConfig + } + .map { config -> + // we operate here with suite names from only those TestConfigs, that have General section with suiteName key + TestSuiteDto( + config.suiteName!!, + config.description, + sourceSnapshot, + config.language, + config.tags + ) + } + .distinct() + .toList() + } + + @Suppress("TYPE_ALIAS", "WRONG_NEWLINES") + private fun PluginConfig.getResourceNames(): Either> { + val resourceNamePattern = try { + Regex(resourceNamePatternStr) + } catch (_: PatternSyntaxException) { + return "Resource name pattern is not a valid regular expression: \"$resourceNamePatternStr\"".left() } - .distinct() - .toList() + + val configLocation = configLocation.toNioPath() + if (!configLocation.isRegularFile()) { + return "Config file doesn't exist: \"$configLocation\"".left() + } + + val testDirectory = configLocation.parent + ?: return "The parent directory of the config file is null: \"$configLocation\"".left() + if (!testDirectory.isDirectory()) { + return "Test directory doesn't exist: \"$testDirectory\"".left() + } + + return testDirectory.listDirectoryEntries().asSequence() + .filter(NioPath::isRegularFile) + .filterNot { file -> + file.name.equals("save.toml", ignoreCase = true) + } + .filterNot { file -> + file.name.equals("save.properties", ignoreCase = true) + } + .map { file -> + file.relativeToOrNull(testDirectory) + } + .filterNotNull() + .filterNot(NioPath::isAbsolute) + .filter { relativeFile -> + relativeFile.name.matches(resourceNamePattern) + } + .right() + } /** * Discover all test suites in the project @@ -141,7 +271,8 @@ class TestDiscoveringService( fun discoverAllTestSuites( rootTestConfig: TestConfig, sourceSnapshot: TestsSourceSnapshotDto, - ) = getAllTestSuites(rootTestConfig, sourceSnapshot) + ): List = + getAllTestSuites(rootTestConfig, sourceSnapshot) private fun Path.getRelativePath(rootTestConfig: TestConfig) = this.toFile() .relativeTo(rootTestConfig.directory.toFile()) diff --git a/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/service/TestSuiteValidationService.kt b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/service/TestSuiteValidationService.kt new file mode 100644 index 0000000000..9590a3ec0a --- /dev/null +++ b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/service/TestSuiteValidationService.kt @@ -0,0 +1,88 @@ +package com.saveourtool.save.preprocessor.service + +import com.saveourtool.save.preprocessor.test.suite.TestSuiteValidator +import com.saveourtool.save.test.TestSuiteValidationError +import com.saveourtool.save.test.TestSuiteValidationResult +import com.saveourtool.save.testsuite.TestSuiteDto +import com.saveourtool.save.utils.getLogger +import org.springframework.jmx.export.annotation.ManagedAttribute +import org.springframework.jmx.export.annotation.ManagedResource +import org.springframework.stereotype.Service +import reactor.core.publisher.Flux +import reactor.core.publisher.ParallelFlux +import reactor.core.scheduler.Schedulers +import java.lang.Runtime.getRuntime +import kotlin.math.min + +/** + * Validates test suites discovered by [TestDiscoveringService]. + * + * @see TestDiscoveringService + */ +@Service +@ManagedResource +@Suppress("WRONG_ORDER_IN_CLASS_LIKE_STRUCTURES") +class TestSuiteValidationService(private val validators: Array) { + init { + if (validators.isEmpty()) { + logger.warn("No test suite validators configured.") + } + } + + @Suppress( + "CUSTOM_GETTERS_SETTERS", + "WRONG_INDENTATION", + ) + private val parallelism: Int + get() = + when { + validators.isEmpty() -> 1 + else -> min(validators.size, getRuntime().availableProcessors()) + } + + /** + * @return the class names of discovered validators. + */ + @Suppress( + "CUSTOM_GETTERS_SETTERS", + "WRONG_INDENTATION", + ) + @get:ManagedAttribute + val validatorTypes: List + get() = + validators.asSequence() + .map(TestSuiteValidator::javaClass) + .map(Class::getName) + .toList() + + /** + * Invokes all discovered validators and checks [testSuites]. + * + * @param testSuites the test suites to check. + * @return the [Flux] of intermediate status updates terminated with the + * final update for each check discovered. + */ + fun validateAll(testSuites: List): ParallelFlux = + when { + testSuites.isEmpty() -> Flux.just( + TestSuiteValidationError(javaClass.name, "Common", "No test suites found") + ).parallel(parallelism) + + validators.isEmpty() -> Flux.empty().parallel(parallelism) + + else -> validators.asSequence() + .map { validator -> + validator.validate(testSuites) + } + .reduce { left, right -> + left.mergeWith(right) + } + .parallel(parallelism) + .runOn(Schedulers.parallel()) + } + + private companion object { + @Suppress("GENERIC_VARIABLE_WRONG_DECLARATION") + private val logger = getLogger() + } +} diff --git a/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/service/TestSuiteValidatorClient.kt b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/service/TestSuiteValidatorClient.kt new file mode 100644 index 0000000000..be7d130298 --- /dev/null +++ b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/service/TestSuiteValidatorClient.kt @@ -0,0 +1,50 @@ +package com.saveourtool.save.preprocessor.service + +import com.saveourtool.save.test.TestSuiteValidationError +import com.saveourtool.save.test.TestSuiteValidationProgress +import com.saveourtool.save.utils.getLogger +import org.springframework.jmx.export.annotation.ManagedOperation +import org.springframework.jmx.export.annotation.ManagedResource +import org.springframework.stereotype.Service + +@Suppress( + "MISSING_KDOC_TOP_LEVEL", + "MISSING_KDOC_CLASS_ELEMENTS", + "MISSING_KDOC_ON_FUNCTION" +) +@Service +@ManagedResource +class TestSuiteValidatorClient(private val service: TestSuiteValidationService) { + @ManagedOperation + fun validate() { + service.validateAll(emptyList()) + .subscribe() + } + + @ManagedOperation + fun validateAndRequestSingle() { + service.validateAll(emptyList()) + .doOnNext { status -> + when (status) { + is TestSuiteValidationProgress -> logger.info("First status event received: ${status.percentage}%") + is TestSuiteValidationError -> logger.info("Error: ${status.message}") + } + } + .sequential() + .next() + .block() + } + + @ManagedOperation + fun validateAndCancel() { + service.validateAll(emptyList()) + .subscribe() + .dispose() + logger.info("Subscription for status update events cancelled.") + } + + private companion object { + @Suppress("GENERIC_VARIABLE_WRONG_DECLARATION") + private val logger = getLogger() + } +} diff --git a/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/test/suite/AbstractTestSuiteValidator.kt b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/test/suite/AbstractTestSuiteValidator.kt new file mode 100644 index 0000000000..07678675a3 --- /dev/null +++ b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/test/suite/AbstractTestSuiteValidator.kt @@ -0,0 +1,57 @@ +package com.saveourtool.save.preprocessor.test.suite + +import com.saveourtool.save.test.TestSuiteValidationResult +import com.saveourtool.save.testsuite.TestSuiteDto +import com.saveourtool.save.utils.getLogger +import reactor.core.publisher.Flux +import reactor.core.scheduler.Schedulers + +/** + * The common part of [TestSuiteValidator] implementations. + */ +abstract class AbstractTestSuiteValidator : TestSuiteValidator { + private val logger = getLogger(javaClass) + + /** + * Validates test suites. + * + * @param testSuites the test suites to check. + * @param onStatusUpdate the callback to invoke when there's a validation + * status update. + */ + protected abstract fun validate( + testSuites: List, + onStatusUpdate: (status: TestSuiteValidationResult) -> Unit, + ) + + final override fun validate(testSuites: List): Flux = + Flux + .create { sink -> + validate(testSuites) { status -> + sink.next(status) + } + sink.complete() + } + + /* + * Should never be invoked, since this will be a hot Flux. + */ + .doOnCancel { + logger.warn("Validator ${javaClass.simpleName} cancelled.") + } + + /* + * Off-load from the main thread. + */ + .subscribeOn(Schedulers.boundedElastic()) + + /*- + * Turn this cold Flux into a hot one. + * + * `cache()` is identical to `replay(history = Int.MAX_VALUE).autoConnect(minSubscribers = 1)`. + * + * We want `replay()` instead of `publish()`, so that late + * subscribers, if any, will observe early published data. + */ + .cache() +} diff --git a/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/test/suite/PluginsWithoutTests.kt b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/test/suite/PluginsWithoutTests.kt new file mode 100644 index 0000000000..45794a94a8 --- /dev/null +++ b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/test/suite/PluginsWithoutTests.kt @@ -0,0 +1,33 @@ +package com.saveourtool.save.preprocessor.test.suite + +import com.saveourtool.save.test.TestSuiteValidationProgress +import com.saveourtool.save.test.TestSuiteValidationResult +import com.saveourtool.save.testsuite.TestSuiteDto +import com.saveourtool.save.utils.getLogger + +/** + * Plug-ins without tests. + */ +@TestSuiteValidatorComponent +class PluginsWithoutTests : AbstractTestSuiteValidator() { + override fun validate( + testSuites: List, + onStatusUpdate: (status: TestSuiteValidationResult) -> Unit, + ) { + require(testSuites.isNotEmpty()) + + @Suppress("MAGIC_NUMBER") + for (i in 0..10) { + val status = TestSuiteValidationProgress(javaClass.name, CHECK_NAME, i * 10) + logger.info("Emitting \"$status\"...") + onStatusUpdate(status) + Thread.sleep(500L) + } + } + + private companion object { + @Suppress("GENERIC_VARIABLE_WRONG_DECLARATION") + private val logger = getLogger() + private const val CHECK_NAME = "Searching for plug-ins with zero tests" + } +} diff --git a/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/test/suite/TestSuiteValidator.kt b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/test/suite/TestSuiteValidator.kt new file mode 100644 index 0000000000..5ee81b6147 --- /dev/null +++ b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/test/suite/TestSuiteValidator.kt @@ -0,0 +1,36 @@ +package com.saveourtool.save.preprocessor.test.suite + +import com.saveourtool.save.test.TestSuiteValidationResult +import com.saveourtool.save.testsuite.TestSuiteDto +import reactor.core.publisher.Flux +import reactor.core.scheduler.Scheduler +import reactor.core.scheduler.Schedulers + +/** + * A particular validation check. + * + * Implementations _should_: + * - make sure the [Flux] returned by [validate] is a hot [Flux], so that + * cancelling a particular subscriber (e.g.: in case of a network outage) + * doesn't affect validation; + * - off-load the actual work to a separate [Scheduler], such as + * [Schedulers.boundedElastic]. + * - be annotated with [TestSuiteValidatorComponent]. + * + * Implementations _may_: + * - inherit from [AbstractTestSuiteValidator]. + * + * @see TestSuiteValidatorComponent + * @see AbstractTestSuiteValidator + */ +fun interface TestSuiteValidator { + /** + * Validates test suites, returning a [Flux] of intermediate status updates + * terminated with the final update. + * + * @param testSuites the test suites to check. + * @return the [Flux] of intermediate status updates terminated with the + * final update. + */ + fun validate(testSuites: List): Flux +} diff --git a/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/test/suite/TestSuiteValidatorComponent.kt b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/test/suite/TestSuiteValidatorComponent.kt new file mode 100644 index 0000000000..938334b305 --- /dev/null +++ b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/test/suite/TestSuiteValidatorComponent.kt @@ -0,0 +1,12 @@ +package com.saveourtool.save.preprocessor.test.suite + +import org.springframework.stereotype.Component + +/** + * Can be used to annotate implementations of [TestSuiteValidator] so that + * they're discoverable by _Spring_. + * + * @see TestSuiteValidator + */ +@Component +annotation class TestSuiteValidatorComponent diff --git a/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/test/suite/TestSuitesWithWildcardMode.kt b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/test/suite/TestSuitesWithWildcardMode.kt new file mode 100644 index 0000000000..2aaedc85d2 --- /dev/null +++ b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/test/suite/TestSuitesWithWildcardMode.kt @@ -0,0 +1,33 @@ +package com.saveourtool.save.preprocessor.test.suite + +import com.saveourtool.save.test.TestSuiteValidationProgress +import com.saveourtool.save.test.TestSuiteValidationResult +import com.saveourtool.save.testsuite.TestSuiteDto +import com.saveourtool.save.utils.getLogger + +/** + * Test suites with wildcard mode. + */ +@TestSuiteValidatorComponent +class TestSuitesWithWildcardMode : AbstractTestSuiteValidator() { + override fun validate( + testSuites: List, + onStatusUpdate: (status: TestSuiteValidationResult) -> Unit, + ) { + require(testSuites.isNotEmpty()) + + @Suppress("MAGIC_NUMBER") + for (i in 0..10) { + val status = TestSuiteValidationProgress(javaClass.name, CHECK_NAME, i * 10) + logger.info("Emitting \"$status\"...") + onStatusUpdate(status) + Thread.sleep(500L) + } + } + + private companion object { + @Suppress("GENERIC_VARIABLE_WRONG_DECLARATION") + private val logger = getLogger() + private const val CHECK_NAME = "Searching for test suites with wildcard mode" + } +} diff --git a/save-preprocessor/src/test/kotlin/com/saveourtool/save/preprocessor/controllers/TestSuitesPreprocessorControllerTest.kt b/save-preprocessor/src/test/kotlin/com/saveourtool/save/preprocessor/controllers/TestSuitesPreprocessorControllerTest.kt index dee7036b07..ddf54b9e8f 100644 --- a/save-preprocessor/src/test/kotlin/com/saveourtool/save/preprocessor/controllers/TestSuitesPreprocessorControllerTest.kt +++ b/save-preprocessor/src/test/kotlin/com/saveourtool/save/preprocessor/controllers/TestSuitesPreprocessorControllerTest.kt @@ -2,9 +2,12 @@ package com.saveourtool.save.preprocessor.controllers import com.saveourtool.save.entities.GitDto import com.saveourtool.save.entities.TestSuite +import com.saveourtool.save.preprocessor.common.CloneResult +import com.saveourtool.save.preprocessor.common.GitRepositoryProcessor import com.saveourtool.save.preprocessor.service.* import com.saveourtool.save.preprocessor.utils.GitCommitInfo import com.saveourtool.save.request.TestsSourceFetchRequest +import com.saveourtool.save.test.TestSuiteValidationResult import com.saveourtool.save.test.TestsSourceSnapshotDto import com.saveourtool.save.test.TestsSourceVersionDto import com.saveourtool.save.testsuite.TestSuitesSourceDto @@ -14,6 +17,7 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.mockito.invocation.InvocationOnMock import org.mockito.kotlin.* +import reactor.core.publisher.Flux import reactor.core.publisher.Mono import java.nio.file.Paths @@ -63,15 +67,15 @@ internal class TestSuitesPreprocessorControllerTest { fun setup() { val answerCall = { answer: InvocationOnMock -> @Suppress("UNCHECKED_CAST") - val processor = answer.arguments[2] as GitRepositoryProcessor - processor(repositoryDirectory, GitCommitInfo(fullCommit, commitTime)) + val processor = answer.arguments[2] as GitRepositoryProcessor> + processor.processAsync(CloneResult(repositoryDirectory, GitCommitInfo(fullCommit, commitTime))) } doAnswer(answerCall).whenever(gitPreprocessorService) - .cloneTagAndProcessDirectory(eq(gitDto), eq(tag), any()) + .cloneTagAndProcessDirectoryMany(eq(gitDto), eq(tag), any()) doAnswer(answerCall).whenever(gitPreprocessorService) - .cloneBranchAndProcessDirectory(eq(gitDto), eq(branch), any()) + .cloneBranchAndProcessDirectoryMany(eq(gitDto), eq(branch), any()) doAnswer(answerCall).whenever(gitPreprocessorService) - .cloneCommitAndProcessDirectory(eq(gitDto), eq(commit), any()) + .cloneCommitAndProcessDirectoryMany(eq(gitDto), eq(commit), any()) doAnswer { answer -> @Suppress("UNCHECKED_CAST") val processor = answer.arguments[1] as ArchiveProcessor @@ -92,9 +96,9 @@ internal class TestSuitesPreprocessorControllerTest { whenever(testsPreprocessorToBackendBridge.findTestsSourceSnapshot(any(), any())) .thenReturn(Mono.empty()) testSuitesPreprocessorController.fetch(TestsSourceFetchRequest(testSuitesSourceDto, TestSuitesSourceFetchMode.BY_TAG, tag, userId)) - .block() + .blockLast() - verify(gitPreprocessorService).cloneTagAndProcessDirectory(eq(gitDto), eq(tag), any()) + verify(gitPreprocessorService).cloneTagAndProcessDirectoryMany(eq(gitDto), eq(tag), any()) verify(testsPreprocessorToBackendBridge).saveTestsSourceVersion(testsSourceVersionDto(tag, TestSuitesSourceFetchMode.BY_TAG)) verifyNewCommit() } @@ -104,9 +108,9 @@ internal class TestSuitesPreprocessorControllerTest { whenever(testsPreprocessorToBackendBridge.findTestsSourceSnapshot(any(), any())) .thenReturn(Mono.just(testsSourceSnapshotDto)) testSuitesPreprocessorController.fetch(TestsSourceFetchRequest(testSuitesSourceDto, TestSuitesSourceFetchMode.BY_TAG, tag, userId)) - .block() + .blockLast() - verify(gitPreprocessorService).cloneTagAndProcessDirectory(eq(gitDto), eq(tag), any()) + verify(gitPreprocessorService).cloneTagAndProcessDirectoryMany(eq(gitDto), eq(tag), any()) verify(testsPreprocessorToBackendBridge).saveTestsSourceVersion(testsSourceVersionDto(tag, TestSuitesSourceFetchMode.BY_TAG)) verifyExistedCommit() } @@ -116,9 +120,9 @@ internal class TestSuitesPreprocessorControllerTest { whenever(testsPreprocessorToBackendBridge.findTestsSourceSnapshot(any(), any())) .thenReturn(Mono.empty()) testSuitesPreprocessorController.fetch(TestsSourceFetchRequest(testSuitesSourceDto, TestSuitesSourceFetchMode.BY_BRANCH, branch, userId)) - .block() + .blockLast() - verify(gitPreprocessorService).cloneBranchAndProcessDirectory(eq(gitDto), eq(branch), any()) + verify(gitPreprocessorService).cloneBranchAndProcessDirectoryMany(eq(gitDto), eq(branch), any()) verify(testsPreprocessorToBackendBridge).saveTestsSourceVersion(testsSourceVersionDto(branchVersion, TestSuitesSourceFetchMode.BY_BRANCH)) verifyNewCommit() } @@ -128,9 +132,9 @@ internal class TestSuitesPreprocessorControllerTest { whenever(testsPreprocessorToBackendBridge.findTestsSourceSnapshot(any(), any())) .thenReturn(Mono.empty()) testSuitesPreprocessorController.fetch(TestsSourceFetchRequest(testSuitesSourceDto, TestSuitesSourceFetchMode.BY_COMMIT, commit, userId)) - .block() + .blockLast() - verify(gitPreprocessorService).cloneCommitAndProcessDirectory(eq(gitDto), eq(commit), any()) + verify(gitPreprocessorService).cloneCommitAndProcessDirectoryMany(eq(gitDto), eq(commit), any()) verify(testsPreprocessorToBackendBridge).saveTestsSourceVersion(testsSourceVersionDto(commit, TestSuitesSourceFetchMode.BY_COMMIT)) verifyNewCommit() } @@ -140,9 +144,9 @@ internal class TestSuitesPreprocessorControllerTest { whenever(testsPreprocessorToBackendBridge.findTestsSourceSnapshot(any(), any())) .thenReturn(Mono.just(testsSourceSnapshotDto)) testSuitesPreprocessorController.fetch(TestsSourceFetchRequest(testSuitesSourceDto, TestSuitesSourceFetchMode.BY_COMMIT, commit, userId)) - .block() + .blockLast() - verify(gitPreprocessorService).cloneCommitAndProcessDirectory(eq(gitDto), eq(commit), any()) + verify(gitPreprocessorService).cloneCommitAndProcessDirectoryMany(eq(gitDto), eq(commit), any()) verify(testsPreprocessorToBackendBridge).saveTestsSourceVersion(testsSourceVersionDto(commit, TestSuitesSourceFetchMode.BY_COMMIT)) verifyExistedCommit() } diff --git a/save-preprocessor/src/test/kotlin/com/saveourtool/save/preprocessor/service/TestSuiteValidationServiceTest.kt b/save-preprocessor/src/test/kotlin/com/saveourtool/save/preprocessor/service/TestSuiteValidationServiceTest.kt new file mode 100644 index 0000000000..762882e2d8 --- /dev/null +++ b/save-preprocessor/src/test/kotlin/com/saveourtool/save/preprocessor/service/TestSuiteValidationServiceTest.kt @@ -0,0 +1,41 @@ +package com.saveourtool.save.preprocessor.service + +import com.saveourtool.save.test.TestSuiteValidationError +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.collections.shouldNotHaveSize +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.ComponentScan +import org.springframework.context.annotation.Import +import org.springframework.test.context.junit.jupiter.SpringExtension + +/** + * @see TestSuiteValidationService + */ +@ExtendWith(SpringExtension::class) +@Import(TestSuiteValidationService::class) +@ComponentScan("com.saveourtool.save.preprocessor.test.suite") +class TestSuiteValidationServiceTest { + @Autowired + private lateinit var validationService: TestSuiteValidationService + + @Test + fun `non-empty list of validators`() { + validationService.validatorTypes shouldNotHaveSize 0 + } + + @Test + fun `empty list of test suites should result in a single error`() { + val validationResults = validationService.validateAll(emptyList()).sequential().toIterable().toList() + + validationResults shouldHaveSize 1 + + val validationResult = validationResults[0] + assertInstanceOf(TestSuiteValidationError::class.java, validationResult) + validationResult as TestSuiteValidationError + validationResult.message shouldBe "No test suites found" + } +}