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..c227035a8b --- /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 should be in range of [0..100]: $percentage" + } + } + + 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-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..e663b82e29 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 @@ -46,6 +46,7 @@ import java.nio.file.Path as NioPath @Service class TestDiscoveringService( private val testsPreprocessorToBackendBridge: TestsPreprocessorToBackendBridge, + private val validationService: TestSuiteValidationService, ) { /** * @param repositoryPath 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/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/service/TestDiscoveringServiceTest.kt b/save-preprocessor/src/test/kotlin/com/saveourtool/save/preprocessor/service/TestDiscoveringServiceTest.kt index 2a9e105986..6327cbfc90 100644 --- a/save-preprocessor/src/test/kotlin/com/saveourtool/save/preprocessor/service/TestDiscoveringServiceTest.kt +++ b/save-preprocessor/src/test/kotlin/com/saveourtool/save/preprocessor/service/TestDiscoveringServiceTest.kt @@ -20,6 +20,7 @@ import org.mockito.kotlin.whenever import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.ComponentScan import org.springframework.context.annotation.Import import org.springframework.test.context.TestPropertySource import org.springframework.test.context.junit.jupiter.SpringExtension @@ -34,7 +35,9 @@ import kotlin.io.path.div @Import( TestDiscoveringService::class, TestsPreprocessorToBackendBridge::class, + TestSuiteValidationService::class, ) +@ComponentScan("com.saveourtool.save.preprocessor.test.suite") class TestDiscoveringServiceTest { private val logger = LoggerFactory.getLogger(TestDiscoveringServiceTest::class.java) @Autowired private lateinit var testDiscoveringService: TestDiscoveringService 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" + } +}