Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue/fix gradle filter #3257

Merged
merged 4 commits into from Oct 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -148,9 +148,9 @@ sealed interface Descriptor {
fun getTreePrefix(): List<Descriptor> {
val ret = mutableListOf<Descriptor>()
var x = this
loop@ while(true) {
loop@ while (true) {
ret.add(0, x)
when(x) {
when (x) {
is SpecDescriptor -> {
break@loop
}
Expand Down Expand Up @@ -179,7 +179,7 @@ data class DescriptorId(
/**
* Treats the lhs and rhs both as wildcard regex one by one and check if it matches the other
*/
fun wildCardMatch(id: DescriptorId) :Boolean {
fun wildCardMatch(id: DescriptorId): Boolean {
val thisRegex = with(this.value) {
("\\Q$this\\E").replace("*", "\\E.*\\Q").toRegex()
}
Expand All @@ -204,7 +204,7 @@ fun Descriptor.append(name: String): TestDescriptor =
* This may be the same descriptor that this method is invoked on, if that descriptor
* is a root test.
*/
fun TestDescriptor.root(): TestDescriptor {
tailrec fun TestDescriptor.root(): TestDescriptor {
return when (parent) {
is SpecDescriptor -> this // if my parent is a spec, then I am a root
is TestDescriptor -> parent.root()
Expand Down
@@ -1,6 +1,7 @@
package io.kotest.runner.junit.platform.gradle

import io.kotest.core.descriptors.Descriptor
import io.kotest.core.descriptors.TestPath
import io.kotest.core.filter.TestFilter
import io.kotest.core.filter.TestFilterResult
import io.kotest.mpp.Logger
Expand All @@ -13,70 +14,85 @@ class GradleClassMethodRegexTestFilter(private val patterns: List<String>) : Tes
logger.log { Pair(descriptor.toString(), "Testing against $patterns") }
return when {
patterns.isEmpty() -> TestFilterResult.Include
patterns.any { match(it, descriptor) } -> TestFilterResult.Include
Kantis marked this conversation as resolved.
Show resolved Hide resolved
patterns.all { match(it, descriptor) } -> TestFilterResult.Include
else -> TestFilterResult.Exclude(null)
}
}

/**
* Matches the pattern supplied from gradle build script or command line interface.
*
* supports:
* - gradle test --tests "SomeTest"
* - gradle test --tests "*Test"
* - gradle test --tests "io.package.*"
* - gradle test --tests "io.package"
* - gradle test --tests "io.package.SomeTest"
* - gradle test --tests "io.package.SomeTest.first level context*"
* - gradle test --tests "io.package.SomeTest.*"
* - gradle test --tests "io.*.SomeTest"
* - gradle test --tests "SomeTest.first level context*"
* - gradle test --tests "*.first level context*"
*
* Exact nested context / test matching is NOT CURRENTLY SUPPORTED.
* Kotest support lazy test registration within nested context. Gradle test filter does not
* natively work nicely with kotest. In order to make it work we need to think of a way to
* recursively apply partial context-search as we dive deeper into the contexts.
*
* Notes to Maintainers:
*
* Gradle supplies a pattern string which corresponds to a well-formed regex object.
* This can be directly usable for kotest.
* - A* becomes \QA\E.*
* - A*Test becomes \QA\E.*\QTest\E
* - io.*.A*Test becomes \Qio.\E.*\Q.A\E.*\QTest\E
* - io.*.A*Test.AccountDetails* becomes \Qio.\E.*\Q.A\E.*\QTest.AccountDetails\E.*
* - io.*.A*Test.some test context* becomes \Qio.\E.*\Q.A\E.*\QTest.some test context\E.*
*/
private fun match(pattern: String, descriptor: Descriptor): Boolean {
val (prefixWildcard, pck, classname, path) = GradleTestPattern.parse(pattern)
return when (descriptor) {
is Descriptor.TestDescriptor -> when (path) {
null -> true
else -> descriptor.path(false).value.startsWith(path)
}

is Descriptor.SpecDescriptor -> when {
pck != null && classname != null && prefixWildcard -> descriptor.kclass.qualifiedName?.contains("$pck.$classname") ?: false
pck != null && classname != null -> descriptor.kclass.qualifiedName == "$pck.$classname"
pck != null && prefixWildcard -> descriptor.kclass.qualifiedName?.contains(pck) ?: true
pck != null -> descriptor.kclass.qualifiedName?.startsWith(pck) ?: true
classname != null && prefixWildcard -> descriptor.kclass.simpleName?.contains(classname) ?: false
classname != null -> descriptor.kclass.simpleName == classname
else -> true
}
val path = descriptor.dotSeparatedFullPath().value
val regexPattern = "^(.*)$pattern".toRegex() // matches pattern exactly
val laxRegexPattern = "^(.*)$pattern(.*)\$".toRegex() // matches pattern that can be followed by others
val packagePath = descriptor.spec().id.value.split(".").dropLast(1).joinToString(".") // io.kotest

val isSimpleClassMatch by lazy {
// SomeTest or *Test
descriptor.spec().id.value.split(".").lastOrNull()?.matches(pattern.toRegex()) ?: false
}
val isSpecMatched by lazy { descriptor.spec().id.value.matches(regexPattern) } // *.SomeTest
val isFullPathMatched by lazy { path.matches(regexPattern) } // io.*.SomeTest
val isFullPathDotMatched by lazy { "$path.".matches(regexPattern) } // io.*. or io.*.SomeTest.*

// if there's no uppercase in the supplied pattern, activate trigger relaxed matching
val doesNotContainUppercase by lazy { pattern.replace("\\Q", "").replace("\\E", "").all { !it.isUpperCase() } }

val isPackageMatched by lazy { doesNotContainUppercase && packagePath.matches(laxRegexPattern) } // io.kotest
val isPackageWithDotMatched by lazy { doesNotContainUppercase && "$packagePath.".matches(laxRegexPattern) } // io.kotest.*

return isSimpleClassMatch ||
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sksamuel @Kantis this is the change

isFullPathMatched ||
isFullPathDotMatched ||
isSpecMatched ||
isPackageMatched ||
isPackageWithDotMatched
}
}

data class GradleTestPattern(
val prefixWildcard: Boolean,
val pckage: String?,
val classname: String?,
val path: String?,
) {
companion object {

// if the regex starts with a lower case character, then we assume it is in the format package.Class.testpath
// otherwise, we assume it is in the format Class.testpath
// the .testpath is always optional, and at least Class or package must be specified
// additionally, patterns can start with a * in which case the pattern matches suffixes
fun parse(pattern: String): GradleTestPattern {
require(pattern.isNotBlank())

val prefixWildcard = pattern.startsWith("*")
val pattern2 = pattern
.removePrefix("*")
.removePrefix(".")
.replace("\\Q", "") // Quote start regex, added by Gradle
.replace("\\E", "") // Quote end regex, added by Gradle

val tokens = pattern2.split('.')

// Package names shouldn't contain any upper-case letters
val classIndex = tokens.indexOfFirst { token -> token.any { it.isUpperCase() } }

// if class is not specified, then we assume the entire string is a package
if (classIndex == -1) return GradleTestPattern(prefixWildcard, pattern2, null, null)

// if the class is the first part, then no package is specified
val pck = if (classIndex == 0) null else tokens.take(classIndex).joinToString(".")

val pathParts = tokens.drop(classIndex + 1)
val path = if (pathParts.isEmpty()) null else pathParts.joinToString(".")

return GradleTestPattern(prefixWildcard, pck, tokens[classIndex], path)
/**
* Returns a gradle-compatible dot-separated full path of the given descriptor.
* i.e. io.package.MyTest.given something -- should do something
*
* Note: I'm forced to do this... :(
*
* We cannot use the / separator for contexts as gradle rejects that.
* Filters also seemingly only works on first "." after the class. This was severely limiting.
* The other problem is that also means we can't have "." in the test / context path because gradle doesn't
* like it and will not even give us any candidate classes.
*/
private fun Descriptor.dotSeparatedFullPath(): TestPath = when (this) {
is Descriptor.SpecDescriptor -> TestPath(this.id.value)
is Descriptor.TestDescriptor -> when (this.parent) {
is Descriptor.SpecDescriptor -> TestPath("${this.parent.id.value}.${this.id.value}")
is Descriptor.TestDescriptor -> TestPath("${this.parent.dotSeparatedFullPath().value} -- ${this.id.value}")
}

}
}
Expand Up @@ -32,10 +32,17 @@ object GradlePostDiscoveryFilterExtractor {
val matcher = testMatcher(filter)
logger.log { Pair(null, "TestMatcher [$matcher]") }

val buildScriptIncludePatterns = buildScriptIncludePatterns(matcher)
logger.log { Pair(null, "buildScriptIncludePatterns [$buildScriptIncludePatterns]") }

val commandLineIncludePatterns = commandLineIncludePatterns(matcher)
logger.log { Pair(null, "commandLineIncludePatterns [$commandLineIncludePatterns]") }

val regexes = commandLineIncludePatterns.map { pattern(it) }
val regexes = buildList {
addAll(buildScriptIncludePatterns)
addAll(commandLineIncludePatterns)
}.map { pattern(it) }

logger.log { Pair(null, "ClassMethodNameFilter regexes [$regexes]") }
regexes
}.getOrElse { emptyList() }
Expand All @@ -52,6 +59,12 @@ object GradlePostDiscoveryFilterExtractor {
return field.get(obj) as List<Any>
}

private fun buildScriptIncludePatterns(obj: Any): List<Any> {
val field = obj::class.java.getDeclaredField("buildScriptIncludePatterns")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added support for filters defined in build script

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there build script exclude patterns as well? Not saying we need to fix it now, but could be flagged up as a potential future contribution

field.isAccessible = true
return field.get(obj) as List<Any>
}

private fun pattern(obj: Any): String {
val field = obj::class.java.getDeclaredField("pattern")
field.isAccessible = true
Expand Down
Expand Up @@ -61,7 +61,7 @@ class DiscoveryTest : FunSpec({
.build()
val engine = KotestJunitPlatformTestEngine()
val descriptor = engine.discover(req, UniqueId.forEngine("testengine"))
descriptor.classes.size shouldBe 27
descriptor.classes.size shouldBe 26
}

test("kotest should return classes if request has no included or excluded test engines") {
Expand Down
Expand Up @@ -9,33 +9,31 @@ import io.kotest.matchers.shouldBe

class GradleClassMethodRegexTestFilterTest : FunSpec({

test("include classes") {

context("include classes") {
val spec = GradleClassMethodRegexTestFilterTest::class.toDescriptor()
withData(
nameFn = { filters -> "should be INCLUDED when evaluating $filters" },
listOf("\\QGradleClassMethodRegexTestFilterTest\\E"),
listOf(".*\\QthodRegexTestFilterTest\\E"),
listOf(".*\\QTest\\E"),
listOf("\\Qio.kotest.runner.junit.platform.gradle.GradleClassMethodRegexTestFilterTest\\E"),
listOf(".*\\Q.platform.gradle.GradleClassMethodRegexTestFilterTest\\E"),
listOf(".*\\Qorm.gradle.GradleClassMethodRegexTestFilterTest\\E")
) { filters ->
GradleClassMethodRegexTestFilter(filters).filter(spec) shouldBe TestFilterResult.Include
}
}
Comment on lines +14 to +25
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice :)


GradleClassMethodRegexTestFilter(listOf("GradleClassMethodRegexTestFilterTest")).filter(spec) shouldBe
TestFilterResult.Include

GradleClassMethodRegexTestFilter(listOf("*thodRegexTestFilterTest")).filter(spec) shouldBe
TestFilterResult.Include

GradleClassMethodRegexTestFilter(listOf("GradleClassMethodRegexTestFilterTest2")).filter(spec) shouldBe
TestFilterResult.Exclude(null)

GradleClassMethodRegexTestFilter(listOf("GradleClassMethodRegexTestFilterTes")).filter(spec) shouldBe
TestFilterResult.Exclude(null)

GradleClassMethodRegexTestFilter(listOf("io.kotest.runner.junit.platform.gradle.GradleClassMethodRegexTestFilterTest"))
.filter(spec) shouldBe TestFilterResult.Include

GradleClassMethodRegexTestFilter(listOf("*orm.gradle.GradleClassMethodRegexTestFilterTest"))
.filter(spec) shouldBe TestFilterResult.Include

GradleClassMethodRegexTestFilter(listOf("*.platform.gradle.GradleClassMethodRegexTestFilterTest"))
.filter(spec) shouldBe TestFilterResult.Include

GradleClassMethodRegexTestFilter(listOf("io.kotest.runner.junit.platform.GradleClassMethodRegexTestFilterTest"))
.filter(spec) shouldBe TestFilterResult.Exclude(null)
context("exclude classes") {
val spec = GradleClassMethodRegexTestFilterTest::class.toDescriptor()
withData(
nameFn = { filters -> "should be EXCLUDED when evaluating $filters" },
listOf("\\QGradleClassMethodRegexTestFilterTest2\\E"),
listOf("\\QGradleClassMethodRegexTestFilterTes\\E"),
listOf("\\Qio.kotest.runner.junit.platform.GradleClassMethodRegexTestFilterTest\\E")
) { filters ->
GradleClassMethodRegexTestFilter(filters).filter(spec) shouldBe TestFilterResult.Exclude(null)
}
}

context("include packages") {
Expand All @@ -44,55 +42,61 @@ class GradleClassMethodRegexTestFilterTest : FunSpec({
val container = spec.append("a context")
val test = container.append("nested test")

test("Exact match - includes the spec and tests within it") {
val filter = GradleClassMethodRegexTestFilter(listOf("io.kotest.runner.junit.platform.gradle"))

filter.filter(spec) shouldBe TestFilterResult.Include
filter.filter(test) shouldBe TestFilterResult.Include
withData(
nameFn = { filters -> "should be INCLUDED when evaluating $filters" },
listOf("\\Qio.kotest.runner.junit.platform.gradle\\E"),
listOf("\\Qio.kotest.runner.junit.platform.gradle.\\E.*"),
listOf(".*\\Qnner.junit.platform.gradle\\E"),
listOf(".*\\Qnner.junit.platform.gradle.\\E.*"),
listOf(".*\\Q.junit.platform.gradle\\E"),
listOf("\\Qio.kotest.runner.junit.platform.gra\\E.*"),
listOf("\\Qio.kotest.runner.junit\\E"),
) { filters ->
GradleClassMethodRegexTestFilter(filters).filter(spec) shouldBe TestFilterResult.Include
}

GradleClassMethodRegexTestFilter(listOf("*nner.junit.platform.gradle"))
.filter(spec) shouldBe TestFilterResult.Include

GradleClassMethodRegexTestFilter(listOf("*.junit.platform.gradle"))
.filter(spec) shouldBe TestFilterResult.Include

GradleClassMethodRegexTestFilter(listOf("io.kotest.runner.junit.platform.gra"))
.filter(spec) shouldBe TestFilterResult.Include

GradleClassMethodRegexTestFilter(listOf("io.kotest.runner.junit"))
.filter(spec) shouldBe TestFilterResult.Include
withData(
nameFn = { filters -> "should be EXCLUDED when evaluating $filters" },
listOf("\\Qio.kotest.runner.junit2\\E"),
listOf("\\Qio.kotest.runner.junit\\E", ".*\\QSpec\\E"),
) { filters ->
GradleClassMethodRegexTestFilter(filters).filter(spec) shouldBe TestFilterResult.Exclude(null)
}

GradleClassMethodRegexTestFilter(listOf("io.kotest.runner.junit2"))
.filter(spec) shouldBe TestFilterResult.Exclude(null)
withData(
nameFn = { filters -> "should be INCLUDED when container and test were evaluated using $filters" },
listOf("\\QGradleClassMethodRegexTestFilterTest.a context\\E.*"),
listOf(".*\\QTest\\E", "\\QGradleClassMethodRegex\\E.*\\Q.a context\\E.*"),
) { filters ->
GradleClassMethodRegexTestFilter(filters).filter(container) shouldBe TestFilterResult.Include
GradleClassMethodRegexTestFilter(filters).filter(test) shouldBe TestFilterResult.Include
}
}

context("includes with test paths") {

val spec = GradleClassMethodRegexTestFilterTest::class.toDescriptor()
val container = spec.append("a context")
val test = container.append("nested test")
val fqcn = GradleClassMethodRegexTestFilterTest::class.qualifiedName

val fqcn = "\\Q${GradleClassMethodRegexTestFilterTest::class.qualifiedName}\\E"

withData(
nameFn = { "should be INCLUDED when filter is: $it" },
"$fqcn",
"$fqcn.a context",
"*.gradle.GradleClassMethodRegexTestFilterTest.a context",
"*adle.GradleClassMethodRegexTestFilterTest.a context",
"$fqcn.a context -- nested test",
fqcn,
"$fqcn\\Q.a context\\E.*",
".*\\Q.gradle.GradleClassMethodRegexTestFilterTest.a context\\E.*",
".*\\Qadle.GradleClassMethodRegexTestFilterTest.a context\\E.*"
) { filter ->
GradleClassMethodRegexTestFilter(listOf(filter))
.filter(test) shouldBe TestFilterResult.Include
}

withData(
nameFn = { "should be EXCLUDED when filter is: $it" },
"$fqcn.a context2",
"$fqcn.nested test",
"$fqcn.a context -- nested test2",
"*sMethodRegexTestFilterTest.a context -- nested test2",
"$fqcn\\Q.a context2\\E",
"$fqcn\\Q.nested test\\E",
"$fqcn\\Q.a context.nested test2\\E",
".*\\QsMethodRegexTestFilterTest.a context -- nested test2\\Q",
) { filter ->
GradleClassMethodRegexTestFilter(listOf(filter))
.filter(test) shouldBe TestFilterResult.Exclude(null)
Expand Down