Skip to content

Commit

Permalink
Add the initial implementation on the preprocessor side
Browse files Browse the repository at this point in the history
Related: #1096

Dummy
  • Loading branch information
0x6675636b796f75676974687562 committed Mar 7, 2023
1 parent 2fa8b61 commit 4481d6a
Show file tree
Hide file tree
Showing 11 changed files with 364 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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."
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import java.nio.file.Path as NioPath
@Service
class TestDiscoveringService(
private val testsPreprocessorToBackendBridge: TestsPreprocessorToBackendBridge,
private val validationService: TestSuiteValidationService,
) {
/**
* @param repositoryPath
Expand Down
Original file line number Diff line number Diff line change
@@ -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<out TestSuiteValidator>) {
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<String>
get() =
validators.asSequence()
.map(TestSuiteValidator::javaClass)
.map(Class<TestSuiteValidator>::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<TestSuiteDto>): ParallelFlux<TestSuiteValidationResult> =
when {
testSuites.isEmpty() -> Flux.just<TestSuiteValidationResult>(
TestSuiteValidationError(javaClass.name, "Common", "No test suites found")
).parallel(parallelism)

validators.isEmpty() -> Flux.empty<TestSuiteValidationResult>().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<TestSuiteValidationService>()
}
}
Original file line number Diff line number Diff line change
@@ -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<TestSuiteDto>,
onStatusUpdate: (status: TestSuiteValidationResult) -> Unit,
)

final override fun validate(testSuites: List<TestSuiteDto>): Flux<TestSuiteValidationResult> =
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()
}
Original file line number Diff line number Diff line change
@@ -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<TestSuiteDto>,
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<PluginsWithoutTests>()
private const val CHECK_NAME = "Searching for plug-ins with zero tests"
}
}
Original file line number Diff line number Diff line change
@@ -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<TestSuiteDto>): Flux<TestSuiteValidationResult>
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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<TestSuiteDto>,
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<TestSuitesWithWildcardMode>()
private const val CHECK_NAME = "Searching for test suites with wildcard mode"
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}

0 comments on commit 4481d6a

Please sign in to comment.