aboutsummaryrefslogtreecommitdiff
path: root/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures
diff options
context:
space:
mode:
Diffstat (limited to 'dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures')
-rw-r--r--dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/GradleTestKitUtils.kt274
-rw-r--r--dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/KotestProjectConfig.kt10
-rw-r--r--dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/fileTree.kt61
-rw-r--r--dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/files.kt6
-rw-r--r--dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/gradleRunnerUtils.kt47
-rw-r--r--dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/kotestCollectionMatchers.kt20
-rw-r--r--dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/kotestConditions.kt10
-rw-r--r--dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/kotestGradleAssertions.kt130
-rw-r--r--dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/kotestStringMatchers.kt65
-rw-r--r--dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/samWithReceiverWorkarounds.kt77
-rw-r--r--dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/stringUtils.kt21
-rw-r--r--dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/systemVariableProviders.kt40
-rw-r--r--dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/text.kt24
13 files changed, 785 insertions, 0 deletions
diff --git a/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/GradleTestKitUtils.kt b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/GradleTestKitUtils.kt
new file mode 100644
index 00000000..2f9e1b41
--- /dev/null
+++ b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/GradleTestKitUtils.kt
@@ -0,0 +1,274 @@
+package org.jetbrains.dokka.dokkatoo.utils
+
+import java.io.File
+import java.nio.file.Path
+import java.nio.file.Paths
+import kotlin.properties.PropertyDelegateProvider
+import kotlin.properties.ReadWriteProperty
+import kotlin.reflect.KProperty
+import org.gradle.testkit.runner.GradleRunner
+import org.intellij.lang.annotations.Language
+
+
+// utils for testing using Gradle TestKit
+
+
+class GradleProjectTest(
+ override val projectDir: Path,
+) : ProjectDirectoryScope {
+
+ constructor(
+ testProjectName: String,
+ baseDir: Path = funcTestTempDir,
+ ) : this(projectDir = baseDir.resolve(testProjectName))
+
+ val runner: GradleRunner
+ get() = GradleRunner.create()
+ .withProjectDir(projectDir.toFile())
+ .withJvmArguments(
+ "-XX:MaxMetaspaceSize=512m",
+ "-XX:+AlwaysPreTouch", // https://github.com/gradle/gradle/issues/3093#issuecomment-387259298
+ ).addArguments(
+ // disable the logging task so the tests work consistently on local machines and CI/CD
+ "-P" + "org.jetbrains.dokka.dokkatoo.tasks.logHtmlPublicationLinkEnabled=false"
+ )
+
+ val testMavenRepoRelativePath: String =
+ projectDir.relativize(testMavenRepoDir).toFile().invariantSeparatorsPath
+
+ companion object {
+
+ /** file-based Maven Repo that contains the Dokka dependencies */
+ val testMavenRepoDir: Path by systemProperty(Paths::get)
+
+ val projectTestTempDir: Path by systemProperty(Paths::get)
+
+ /** Temporary directory for the functional tests */
+ val funcTestTempDir: Path by lazy {
+ projectTestTempDir.resolve("functional-tests")
+ }
+
+ /** Dokka Source directory that contains Gradle projects used for integration tests */
+ val integrationTestProjectsDir: Path by systemProperty(Paths::get)
+ /** Dokka Source directory that contains example Gradle projects */
+ val exampleProjectsDir: Path by systemProperty(Paths::get)
+ }
+}
+
+
+///**
+// * Load a project from the [GradleProjectTest.dokkaSrcIntegrationTestProjectsDir]
+// */
+//fun gradleKtsProjectIntegrationTest(
+// testProjectName: String,
+// build: GradleProjectTest.() -> Unit,
+//): GradleProjectTest =
+// GradleProjectTest(
+// baseDir = GradleProjectTest.dokkaSrcIntegrationTestProjectsDir,
+// testProjectName = testProjectName,
+// ).apply(build)
+
+
+/**
+ * Builder for testing a Gradle project that uses Kotlin script DSL and creates default
+ * `settings.gradle.kts` and `gradle.properties` files.
+ *
+ * @param[testProjectName] the path of the project directory, relative to [baseDir
+ */
+fun gradleKtsProjectTest(
+ testProjectName: String,
+ baseDir: Path = GradleProjectTest.funcTestTempDir,
+ build: GradleProjectTest.() -> Unit,
+): GradleProjectTest {
+ return GradleProjectTest(baseDir = baseDir, testProjectName = testProjectName).apply {
+
+ settingsGradleKts = """
+ |rootProject.name = "test"
+ |
+ |@Suppress("UnstableApiUsage")
+ |dependencyResolutionManagement {
+ | repositories {
+ | mavenCentral()
+ | maven(file("$testMavenRepoRelativePath")) {
+ | mavenContent {
+ | includeGroup("org.jetbrains.dokka.dokkatoo")
+ | includeGroup("org.jetbrains.dokka.dokkatoo-html")
+ | }
+ | }
+ | }
+ |}
+ |
+ |pluginManagement {
+ | repositories {
+ | mavenCentral()
+ | gradlePluginPortal()
+ | maven(file("$testMavenRepoRelativePath")) {
+ | mavenContent {
+ | includeGroup("org.jetbrains.dokka.dokkatoo")
+ | includeGroup("org.jetbrains.dokka.dokkatoo-html")
+ | }
+ | }
+ | }
+ |}
+ |
+ """.trimMargin()
+
+ gradleProperties = """
+ |kotlin.mpp.stability.nowarn=true
+ |org.gradle.cache=true
+ """.trimMargin()
+
+ build()
+ }
+}
+
+/**
+ * Builder for testing a Gradle project that uses Groovy script and creates default,
+ * `settings.gradle`, and `gradle.properties` files.
+ *
+ * @param[testProjectName] the name of the test, which should be distinct across the project
+ */
+fun gradleGroovyProjectTest(
+ testProjectName: String,
+ baseDir: Path = GradleProjectTest.funcTestTempDir,
+ build: GradleProjectTest.() -> Unit,
+): GradleProjectTest {
+ return GradleProjectTest(baseDir = baseDir, testProjectName = testProjectName).apply {
+
+ settingsGradle = """
+ |rootProject.name = "test"
+ |
+ |dependencyResolutionManagement {
+ | repositories {
+ | mavenCentral()
+ | maven { url = file("$testMavenRepoRelativePath") }
+ | }
+ |}
+ |
+ |pluginManagement {
+ | repositories {
+ | mavenCentral()
+ | gradlePluginPortal()
+ | maven { url = file("$testMavenRepoRelativePath") }
+ | }
+ |}
+ |
+ """.trimMargin()
+
+ gradleProperties = """
+ |kotlin.mpp.stability.nowarn=true
+ |org.gradle.cache=true
+ """.trimMargin()
+
+ build()
+ }
+}
+
+
+fun GradleProjectTest.projectFile(
+ @Language("TEXT")
+ filePath: String
+): PropertyDelegateProvider<Any?, ReadWriteProperty<Any?, String>> =
+ PropertyDelegateProvider { _, _ ->
+ TestProjectFileProvidedDelegate(this, filePath)
+ }
+
+
+/** Delegate for reading and writing a [GradleProjectTest] file. */
+private class TestProjectFileProvidedDelegate(
+ private val project: GradleProjectTest,
+ private val filePath: String,
+) : ReadWriteProperty<Any?, String> {
+ override fun getValue(thisRef: Any?, property: KProperty<*>): String =
+ project.projectDir.resolve(filePath).toFile().readText()
+
+ override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
+ project.createFile(filePath, value)
+ }
+}
+
+/** Delegate for reading and writing a [GradleProjectTest] file. */
+class TestProjectFileDelegate(
+ private val filePath: String,
+) : ReadWriteProperty<ProjectDirectoryScope, String> {
+ override fun getValue(thisRef: ProjectDirectoryScope, property: KProperty<*>): String =
+ thisRef.projectDir.resolve(filePath).toFile().readText()
+
+ override fun setValue(thisRef: ProjectDirectoryScope, property: KProperty<*>, value: String) {
+ thisRef.createFile(filePath, value)
+ }
+}
+
+
+@DslMarker
+annotation class ProjectDirectoryDsl
+
+@ProjectDirectoryDsl
+interface ProjectDirectoryScope {
+ val projectDir: Path
+}
+
+private data class ProjectDirectoryScopeImpl(
+ override val projectDir: Path
+) : ProjectDirectoryScope
+
+
+fun ProjectDirectoryScope.createFile(filePath: String, contents: String): File =
+ projectDir.resolve(filePath).toFile().apply {
+ parentFile.mkdirs()
+ createNewFile()
+ writeText(contents)
+ }
+
+
+@ProjectDirectoryDsl
+fun ProjectDirectoryScope.dir(
+ path: String,
+ block: ProjectDirectoryScope.() -> Unit = {},
+): ProjectDirectoryScope =
+ ProjectDirectoryScopeImpl(projectDir.resolve(path)).apply(block)
+
+
+@ProjectDirectoryDsl
+fun ProjectDirectoryScope.file(
+ path: String
+): Path = projectDir.resolve(path)
+
+
+fun ProjectDirectoryScope.findFiles(matcher: (File) -> Boolean): Sequence<File> =
+ projectDir.toFile().walk().filter(matcher)
+
+
+/** Set the content of `settings.gradle.kts` */
+@delegate:Language("kts")
+var ProjectDirectoryScope.settingsGradleKts: String by TestProjectFileDelegate("settings.gradle.kts")
+
+
+/** Set the content of `build.gradle.kts` */
+@delegate:Language("kts")
+var ProjectDirectoryScope.buildGradleKts: String by TestProjectFileDelegate("build.gradle.kts")
+
+
+/** Set the content of `settings.gradle` */
+@delegate:Language("groovy")
+var ProjectDirectoryScope.settingsGradle: String by TestProjectFileDelegate("settings.gradle")
+
+
+/** Set the content of `build.gradle` */
+@delegate:Language("groovy")
+var ProjectDirectoryScope.buildGradle: String by TestProjectFileDelegate("build.gradle")
+
+
+/** Set the content of `gradle.properties` */
+@delegate:Language("properties")
+var ProjectDirectoryScope.gradleProperties: String by TestProjectFileDelegate(
+ /* language=text */ "gradle.properties"
+)
+
+
+fun ProjectDirectoryScope.createKotlinFile(filePath: String, @Language("kotlin") contents: String) =
+ createFile(filePath, contents)
+
+
+fun ProjectDirectoryScope.createKtsFile(filePath: String, @Language("kts") contents: String) =
+ createFile(filePath, contents)
diff --git a/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/KotestProjectConfig.kt b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/KotestProjectConfig.kt
new file mode 100644
index 00000000..d6eadba0
--- /dev/null
+++ b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/KotestProjectConfig.kt
@@ -0,0 +1,10 @@
+package org.jetbrains.dokka.dokkatoo.utils
+
+import io.kotest.core.config.AbstractProjectConfig
+
+@Suppress("unused") // this class is automatically picked up by Kotest
+object KotestProjectConfig : AbstractProjectConfig() {
+ init {
+ displayFullTestPath = true
+ }
+}
diff --git a/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/fileTree.kt b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/fileTree.kt
new file mode 100644
index 00000000..4ba850d3
--- /dev/null
+++ b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/fileTree.kt
@@ -0,0 +1,61 @@
+package org.jetbrains.dokka.dokkatoo.utils
+
+import java.io.File
+import java.nio.file.Path
+
+// based on https://gist.github.com/mfwgenerics/d1ec89eb80c95da9d542a03b49b5e15b
+// context: https://kotlinlang.slack.com/archives/C0B8MA7FA/p1676106647658099
+
+fun Path.toTreeString(): String = toFile().toTreeString()
+
+fun File.toTreeString(): String = when {
+ isDirectory -> name + "/\n" + buildTreeString(this)
+ else -> name
+}
+
+private fun buildTreeString(
+ dir: File,
+ margin: String = "",
+): String {
+ val entries = dir.listDirectoryEntries()
+
+ return entries.joinToString("\n") { entry ->
+ val (currentPrefix, nextPrefix) = when (entry) {
+ entries.last() -> PrefixPair.LAST_ENTRY
+ else -> PrefixPair.INTERMEDIATE
+ }
+
+ buildString {
+ append("$margin${currentPrefix}${entry.name}")
+
+ if (entry.isDirectory) {
+ append("/")
+ if (entry.countDirectoryEntries() > 0) {
+ append("\n")
+ }
+ append(buildTreeString(entry, margin + nextPrefix))
+ }
+ }
+ }
+}
+
+private fun File.listDirectoryEntries(): Sequence<File> =
+ walkTopDown().maxDepth(1).filter { it != this@listDirectoryEntries }
+
+
+private fun File.countDirectoryEntries(): Int =
+ listDirectoryEntries().count()
+
+private data class PrefixPair(
+ /** The current entry should be prefixed with this */
+ val currentPrefix: String,
+ /** If the next item is a directory, it should be prefixed with this */
+ val nextPrefix: String,
+) {
+ companion object {
+ /** Prefix pair for a non-last directory entry */
+ val INTERMEDIATE = PrefixPair("├── ", "│ ")
+ /** Prefix pair for the last directory entry */
+ val LAST_ENTRY = PrefixPair("└── ", " ")
+ }
+}
diff --git a/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/files.kt b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/files.kt
new file mode 100644
index 00000000..6a423b55
--- /dev/null
+++ b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/files.kt
@@ -0,0 +1,6 @@
+package org.jetbrains.dokka.dokkatoo.utils
+
+import java.io.File
+
+fun File.copyInto(directory: File, overwrite: Boolean = false) =
+ copyTo(directory.resolve(name), overwrite = overwrite)
diff --git a/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/gradleRunnerUtils.kt b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/gradleRunnerUtils.kt
new file mode 100644
index 00000000..912d1df1
--- /dev/null
+++ b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/gradleRunnerUtils.kt
@@ -0,0 +1,47 @@
+package org.jetbrains.dokka.dokkatoo.utils
+
+import org.gradle.testkit.runner.BuildResult
+import org.gradle.testkit.runner.BuildTask
+import org.gradle.testkit.runner.GradleRunner
+import org.gradle.testkit.runner.internal.DefaultGradleRunner
+
+
+/** Edit environment variables in the Gradle Runner */
+@Deprecated("Windows does not support withEnvironment - https://github.com/gradle/gradle/issues/23959")
+fun GradleRunner.withEnvironment(build: MutableMap<String, String?>.() -> Unit): GradleRunner {
+ val env = environment ?: mutableMapOf()
+ env.build()
+ return withEnvironment(env)
+}
+
+
+inline fun GradleRunner.build(
+ handleResult: BuildResult.() -> Unit
+): Unit = build().let(handleResult)
+
+
+inline fun GradleRunner.buildAndFail(
+ handleResult: BuildResult.() -> Unit
+): Unit = buildAndFail().let(handleResult)
+
+
+fun GradleRunner.withJvmArguments(
+ vararg jvmArguments: String
+): GradleRunner = (this as DefaultGradleRunner).withJvmArguments(*jvmArguments)
+
+
+/**
+ * Helper function to _append_ [arguments] to any existing
+ * [GradleRunner arguments][GradleRunner.getArguments].
+ */
+fun GradleRunner.addArguments(
+ vararg arguments: String
+): GradleRunner =
+ withArguments(this@addArguments.arguments + arguments)
+
+
+/**
+ * Get the name of the task, without the leading [BuildTask.getPath].
+ */
+val BuildTask.name: String
+ get() = path.substringAfterLast(':')
diff --git a/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/kotestCollectionMatchers.kt b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/kotestCollectionMatchers.kt
new file mode 100644
index 00000000..8c33e3eb
--- /dev/null
+++ b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/kotestCollectionMatchers.kt
@@ -0,0 +1,20 @@
+package org.jetbrains.dokka.dokkatoo.utils
+
+import io.kotest.matchers.collections.shouldBeSingleton
+import io.kotest.matchers.maps.shouldContainAll
+import io.kotest.matchers.maps.shouldContainExactly
+
+/** @see io.kotest.matchers.maps.shouldContainAll */
+fun <K, V> Map<K, V>.shouldContainAll(
+ vararg expected: Pair<K, V>
+): Unit = shouldContainAll(expected.toMap())
+
+/** @see io.kotest.matchers.maps.shouldContainExactly */
+fun <K, V> Map<K, V>.shouldContainExactly(
+ vararg expected: Pair<K, V>
+): Unit = shouldContainExactly(expected.toMap())
+
+/** Verify the sequence contains a single element, matching [match]. */
+fun <T> Sequence<T>.shouldBeSingleton(match: (T) -> Unit) {
+ toList().shouldBeSingleton(match)
+}
diff --git a/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/kotestConditions.kt b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/kotestConditions.kt
new file mode 100644
index 00000000..7b692afb
--- /dev/null
+++ b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/kotestConditions.kt
@@ -0,0 +1,10 @@
+package org.jetbrains.dokka.dokkatoo.utils
+
+import io.kotest.core.annotation.EnabledCondition
+import io.kotest.core.spec.Spec
+import kotlin.reflect.KClass
+
+class NotWindowsCondition : EnabledCondition {
+ override fun enabled(kclass: KClass<out Spec>): Boolean =
+ "win" !in System.getProperty("os.name").lowercase()
+}
diff --git a/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/kotestGradleAssertions.kt b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/kotestGradleAssertions.kt
new file mode 100644
index 00000000..e1863c8f
--- /dev/null
+++ b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/kotestGradleAssertions.kt
@@ -0,0 +1,130 @@
+package org.jetbrains.dokka.dokkatoo.utils
+
+import io.kotest.assertions.assertSoftly
+import io.kotest.matchers.*
+import org.gradle.api.NamedDomainObjectCollection
+import org.gradle.testkit.runner.BuildResult
+import org.gradle.testkit.runner.BuildTask
+import org.gradle.testkit.runner.TaskOutcome
+
+infix fun <T : Any> NamedDomainObjectCollection<out T>?.shouldContainDomainObject(
+ name: String
+): T {
+ this should containDomainObject(name)
+ return this?.getByName(name)!!
+}
+
+infix fun <T : Any> NamedDomainObjectCollection<out T>?.shouldNotContainDomainObject(
+ name: String
+): NamedDomainObjectCollection<out T>? {
+ this shouldNot containDomainObject(name)
+ return this
+}
+
+private fun <T> containDomainObject(name: String): Matcher<NamedDomainObjectCollection<T>?> =
+ neverNullMatcher { value ->
+ MatcherResult(
+ name in value.names,
+ { "NamedDomainObjectCollection(${value.names}) should contain DomainObject named '$name'" },
+ { "NamedDomainObjectCollection(${value.names}) should not contain DomainObject named '$name'" })
+ }
+
+/** Assert that a task ran. */
+infix fun BuildResult?.shouldHaveRunTask(taskPath: String): BuildTask {
+ this should haveTask(taskPath)
+ return this?.task(taskPath)!!
+}
+
+/** Assert that a task ran, with an [expected outcome][expectedOutcome]. */
+fun BuildResult?.shouldHaveRunTask(
+ taskPath: String,
+ expectedOutcome: TaskOutcome
+): BuildTask {
+ this should haveTask(taskPath)
+ val task = this?.task(taskPath)!!
+ task should haveOutcome(expectedOutcome)
+ return task
+}
+
+/**
+ * Assert that a task did not run.
+ *
+ * A task might not have run if one of its dependencies failed before it could be run.
+ */
+infix fun BuildResult?.shouldNotHaveRunTask(taskPath: String) {
+ this shouldNot haveTask(taskPath)
+}
+
+private fun haveTask(taskPath: String): Matcher<BuildResult?> =
+ neverNullMatcher { value ->
+ MatcherResult(
+ value.task(taskPath) != null,
+ { "BuildResult should have run task $taskPath. All tasks: ${value.tasks.joinToString { it.path }}" },
+ { "BuildResult should not have run task $taskPath. All tasks: ${value.tasks.joinToString { it.path }}" },
+ )
+ }
+
+
+infix fun BuildTask?.shouldHaveOutcome(outcome: TaskOutcome) {
+ this should haveOutcome(outcome)
+}
+
+
+infix fun BuildTask?.shouldHaveAnyOutcome(outcomes: Collection<TaskOutcome>) {
+ this should haveAnyOutcome(outcomes)
+}
+
+
+infix fun BuildTask?.shouldNotHaveOutcome(outcome: TaskOutcome) {
+ this shouldNot haveOutcome(outcome)
+}
+
+
+private fun haveOutcome(outcome: TaskOutcome): Matcher<BuildTask?> =
+ haveAnyOutcome(listOf(outcome))
+
+
+private fun haveAnyOutcome(outcomes: Collection<TaskOutcome>): Matcher<BuildTask?> {
+ val shouldHaveOutcome = when (outcomes.size) {
+ 0 -> error("Must provide 1 or more expected task outcome, but received none")
+ 1 -> "should have outcome ${outcomes.first().name}"
+ else -> "should have any outcome of ${outcomes.joinToString()}"
+ }
+
+ return neverNullMatcher { value ->
+ MatcherResult(
+ value.outcome in outcomes,
+ { "Task ${value.path} $shouldHaveOutcome, but was ${value.outcome}" },
+ { "Task ${value.path} $shouldHaveOutcome, but was ${value.outcome}" },
+ )
+ }
+}
+
+fun BuildResult.shouldHaveTaskWithOutcome(taskPath: String, outcome: TaskOutcome) {
+ this shouldHaveRunTask taskPath shouldHaveOutcome outcome
+}
+
+
+fun BuildResult.shouldHaveTaskWithAnyOutcome(taskPath: String, outcomes: Collection<TaskOutcome>) {
+ this shouldHaveRunTask taskPath shouldHaveAnyOutcome outcomes
+}
+
+fun BuildResult.shouldHaveTasksWithOutcome(
+ vararg taskPathToExpectedOutcome: Pair<String, TaskOutcome>
+) {
+ assertSoftly {
+ taskPathToExpectedOutcome.forEach { (taskPath, outcome) ->
+ shouldHaveTaskWithOutcome(taskPath, outcome)
+ }
+ }
+}
+
+fun BuildResult.shouldHaveTasksWithAnyOutcome(
+ vararg taskPathToExpectedOutcome: Pair<String, Collection<TaskOutcome>>
+) {
+ assertSoftly {
+ taskPathToExpectedOutcome.forEach { (taskPath, outcomes) ->
+ shouldHaveTaskWithAnyOutcome(taskPath, outcomes)
+ }
+ }
+}
diff --git a/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/kotestStringMatchers.kt b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/kotestStringMatchers.kt
new file mode 100644
index 00000000..58bbe768
--- /dev/null
+++ b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/kotestStringMatchers.kt
@@ -0,0 +1,65 @@
+package org.jetbrains.dokka.dokkatoo.utils
+
+import io.kotest.assertions.print.print
+import io.kotest.matchers.MatcherResult
+import io.kotest.matchers.neverNullMatcher
+import io.kotest.matchers.should
+import io.kotest.matchers.shouldNot
+
+
+infix fun String?.shouldContainAll(substrings: Iterable<String>): String? {
+ this should containAll(substrings)
+ return this
+}
+
+infix fun String?.shouldNotContainAll(substrings: Iterable<String>): String? {
+ this shouldNot containAll(substrings)
+ return this
+}
+
+fun String?.shouldContainAll(vararg substrings: String): String? {
+ this should containAll(substrings.asList())
+ return this
+}
+
+fun String?.shouldNotContainAll(vararg substrings: String): String? {
+ this shouldNot containAll(substrings.asList())
+ return this
+}
+
+private fun containAll(substrings: Iterable<String>) =
+ neverNullMatcher<String> { value ->
+ MatcherResult(
+ substrings.all { it in value },
+ { "${value.print().value} should include substrings ${substrings.print().value}" },
+ { "${value.print().value} should not include substrings ${substrings.print().value}" })
+ }
+
+
+infix fun String?.shouldContainAnyOf(substrings: Iterable<String>): String? {
+ this should containAnyOf(substrings)
+ return this
+}
+
+infix fun String?.shouldNotContainAnyOf(substrings: Iterable<String>): String? {
+ this shouldNot containAnyOf(substrings)
+ return this
+}
+
+fun String?.shouldContainAnyOf(vararg substrings: String): String? {
+ this should containAnyOf(substrings.asList())
+ return this
+}
+
+fun String?.shouldNotContainAnyOf(vararg substrings: String): String? {
+ this shouldNot containAnyOf(substrings.asList())
+ return this
+}
+
+private fun containAnyOf(substrings: Iterable<String>) =
+ neverNullMatcher<String> { value ->
+ MatcherResult(
+ substrings.any { it in value },
+ { "${value.print().value} should include any of these substrings ${substrings.print().value}" },
+ { "${value.print().value} should not include any of these substrings ${substrings.print().value}" })
+ }
diff --git a/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/samWithReceiverWorkarounds.kt b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/samWithReceiverWorkarounds.kt
new file mode 100644
index 00000000..62cd5860
--- /dev/null
+++ b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/samWithReceiverWorkarounds.kt
@@ -0,0 +1,77 @@
+@file:Suppress("FunctionName")
+
+package org.jetbrains.dokka.dokkatoo.utils
+
+import org.jetbrains.dokka.dokkatoo.dokka.parameters.DokkaPackageOptionsSpec
+import org.jetbrains.dokka.dokkatoo.dokka.parameters.DokkaSourceLinkSpec
+import org.jetbrains.dokka.dokkatoo.dokka.parameters.DokkaSourceSetSpec
+import org.gradle.api.DomainObjectCollection
+import org.gradle.api.NamedDomainObjectContainer
+import org.gradle.api.NamedDomainObjectProvider
+import org.gradle.api.Project
+import org.gradle.api.artifacts.Configuration
+import org.gradle.api.artifacts.DependencySet
+
+
+/**
+ * Workarounds because `SamWithReceiver` not working in test sources
+ * https://youtrack.jetbrains.com/issue/KTIJ-14684
+ *
+ * The `SamWithReceiver` plugin is automatically applied by the `kotlin-dsl` plugin.
+ * It converts all [org.gradle.api.Action] so the parameter is the receiver:
+ *
+ * ```
+ * // with SamWithReceiver ✅
+ * tasks.configureEach {
+ * val task: Task = this
+ * }
+ *
+ * // without SamWithReceiver
+ * tasks.configureEach { it ->
+ * val task: Task = it
+ * }
+ * ```
+ *
+ * This is nice because it means that the Dokka Gradle Plugin more closely matches `build.gradle.kts` files.
+ *
+ * However, [IntelliJ is bugged](https://youtrack.jetbrains.com/issue/KTIJ-14684) and doesn't
+ * acknowledge that `SamWithReceiver` has been applied in test sources. The code works and compiles,
+ * but IntelliJ shows red errors.
+ *
+ * These functions are workarounds, and should be removed ASAP.
+ */
+@Suppress("unused")
+private object Explain
+
+fun Project.subprojects_(configure: Project.() -> Unit) =
+ subprojects(configure)
+
+@Suppress("SpellCheckingInspection")
+fun Project.allprojects_(configure: Project.() -> Unit) =
+ allprojects(configure)
+
+fun <T> DomainObjectCollection<T>.configureEach_(configure: T.() -> Unit) =
+ configureEach(configure)
+
+fun <T> DomainObjectCollection<T>.all_(configure: T.() -> Unit) =
+ all(configure)
+
+fun Configuration.withDependencies_(action: DependencySet.() -> Unit): Configuration =
+ withDependencies(action)
+
+fun <T> NamedDomainObjectContainer<T>.create_(name: String, configure: T.() -> Unit = {}): T =
+ create(name, configure)
+
+fun <T> NamedDomainObjectContainer<T>.register_(
+ name: String,
+ configure: T.() -> Unit
+): NamedDomainObjectProvider<T> =
+ register(name, configure)
+
+fun DokkaSourceSetSpec.sourceLink_(
+ action: DokkaSourceLinkSpec.() -> Unit
+): Unit = sourceLink(action)
+
+fun DokkaSourceSetSpec.perPackageOption_(
+ action: DokkaPackageOptionsSpec.() -> Unit
+): Unit = perPackageOption(action)
diff --git a/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/stringUtils.kt b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/stringUtils.kt
new file mode 100644
index 00000000..eb8777e7
--- /dev/null
+++ b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/stringUtils.kt
@@ -0,0 +1,21 @@
+package org.jetbrains.dokka.dokkatoo.utils
+
+
+fun String.splitToPair(delimiter: String): Pair<String, String> =
+ substringBefore(delimiter) to substringAfter(delimiter)
+
+
+/** Title case the first char of a string */
+fun String.uppercaseFirstChar(): String = mapFirstChar(Character::toTitleCase)
+
+
+private inline fun String.mapFirstChar(
+ transform: (Char) -> Char
+): String = if (isNotEmpty()) transform(this[0]) + substring(1) else this
+
+
+/** Split a string into lines, sort the lines, and re-join them (using [separator]). */
+fun String.sortLines(separator: String = "\n") =
+ lines()
+ .sorted()
+ .joinToString(separator)
diff --git a/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/systemVariableProviders.kt b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/systemVariableProviders.kt
new file mode 100644
index 00000000..b15b3edb
--- /dev/null
+++ b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/systemVariableProviders.kt
@@ -0,0 +1,40 @@
+package org.jetbrains.dokka.dokkatoo.utils
+
+import kotlin.properties.ReadOnlyProperty
+
+// Utilities for fetching System Properties and Environment Variables via delegated properties
+
+
+internal fun optionalSystemProperty() = optionalSystemProperty { it }
+
+internal fun <T : Any> optionalSystemProperty(
+ convert: (String) -> T?
+): ReadOnlyProperty<Any, T?> =
+ ReadOnlyProperty { _, property ->
+ val value = System.getProperty(property.name)
+ if (value != null) convert(value) else null
+ }
+
+
+internal fun systemProperty() = systemProperty { it }
+
+internal fun <T> systemProperty(
+ convert: (String) -> T
+): ReadOnlyProperty<Any, T> =
+ ReadOnlyProperty { _, property ->
+ val value = requireNotNull(System.getProperty(property.name)) {
+ "system property ${property.name} is unavailable"
+ }
+ convert(value)
+ }
+
+
+internal fun optionalEnvironmentVariable() = optionalEnvironmentVariable { it }
+
+internal fun <T : Any> optionalEnvironmentVariable(
+ convert: (String) -> T?
+): ReadOnlyProperty<Any, T?> =
+ ReadOnlyProperty { _, property ->
+ val value = System.getenv(property.name)
+ if (value != null) convert(value) else null
+ }
diff --git a/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/text.kt b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/text.kt
new file mode 100644
index 00000000..ce0ebd9d
--- /dev/null
+++ b/dokka-runners/dokkatoo/modules/dokkatoo-plugin/src/testFixtures/kotlin/text.kt
@@ -0,0 +1,24 @@
+package org.jetbrains.dokka.dokkatoo.utils
+
+/** Replace all newlines with `\n`, so the String can be used in assertions cross-platform */
+fun String.invariantNewlines(): String =
+ lines().joinToString("\n")
+
+fun Pair<String, String>.sideBySide(
+ buffer: String = " ",
+): String {
+ val (left, right) = this
+
+ val leftLines = left.lines()
+ val rightLines = right.lines()
+
+ val maxLeftWidth = leftLines.maxOf { it.length }
+
+ return (0..maxOf(leftLines.size, rightLines.size)).joinToString("\n") { i ->
+
+ val leftLine = (leftLines.getOrNull(i) ?: "").padEnd(maxLeftWidth, ' ')
+ val rightLine = rightLines.getOrNull(i) ?: ""
+
+ leftLine + buffer + rightLine
+ }
+}