From 7043fe36aac067547d0db11317cf6a2e7056c79f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:30:05 +0000 Subject: [PATCH 1/2] Initial plan From 9f010cd4da8da4a080330b024832b64aecc9319e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 15:22:04 +0000 Subject: [PATCH 2/2] Make kotest variant multiplatform (JVM + JS/Node.js) using okio Agent-Logs-Url: https://github.com/jGleitz/testfiles/sessions/3e22e0fc-443c-4534-9936-76424cfd7d49 Co-authored-by: jGleitz <4305652+jGleitz@users.noreply.github.com> --- base/build.gradle.kts | 62 ++-- .../testfiles/DefaultTestFiles.kt | 203 +++++++++++ .../joshuagleitze/testfiles/DeletionMode.kt | 19 + .../de/joshuagleitze/testfiles/TestFiles.kt | 31 ++ .../testfiles/internal/absolutize.kt | 5 + .../internal/invalidFileNameCharacters.kt | 3 + .../testfiles/internal/platformFileSystem.kt | 7 + .../testfiles/internal/synchronizedAccess.kt | 3 + .../testfiles/internal/JsNodeFileSystem.kt | 127 +++++++ .../testfiles/internal/absolutize.kt | 9 + .../internal/invalidFileNameCharacters.kt | 11 + .../testfiles/internal/platformFileSystem.kt | 7 + .../testfiles/internal/synchronizedAccess.kt | 4 + .../testfiles/internal/absolutize.kt | 9 + .../internal/invalidFileNameCharacters.kt | 10 + .../testfiles/internal/platformFileSystem.kt | 7 + .../testfiles/internal/synchronizedAccess.kt | 3 + .../testfiles/DefaultTestFilesSpec.kt | 336 ++++++++++++++++++ .../testfiles/OkioPathAssertions.kt | 25 ++ .../testfiles/testFilesFileRoot.kt | 42 +++ build.gradle.kts | 329 ++++++++++------- kotest/build.gradle.kts | 71 ++-- .../kotest/KotestTestFilesAdapter.kt | 41 +++ .../kotest/internal/testScopeName.kt | 5 + .../testfiles/kotest/testFiles.kt | 13 + .../kotest/internal/testScopeName.kt | 6 + .../kotest/KotestTestFilesIntegrationSpec.kt | 25 ++ .../testfiles/kotest/samples/ExampleSpec.kt | 22 ++ .../kotest/internal/testScopeName.kt | 5 + .../kotest/KotestTestFilesIntegrationSpec.kt | 39 ++ .../testfiles/kotest/samples/ExampleSpec.kt | 22 ++ 31 files changed, 1303 insertions(+), 198 deletions(-) create mode 100644 base/src/commonMain/kotlin/de/joshuagleitze/testfiles/DefaultTestFiles.kt create mode 100644 base/src/commonMain/kotlin/de/joshuagleitze/testfiles/DeletionMode.kt create mode 100644 base/src/commonMain/kotlin/de/joshuagleitze/testfiles/TestFiles.kt create mode 100644 base/src/commonMain/kotlin/de/joshuagleitze/testfiles/internal/absolutize.kt create mode 100644 base/src/commonMain/kotlin/de/joshuagleitze/testfiles/internal/invalidFileNameCharacters.kt create mode 100644 base/src/commonMain/kotlin/de/joshuagleitze/testfiles/internal/platformFileSystem.kt create mode 100644 base/src/commonMain/kotlin/de/joshuagleitze/testfiles/internal/synchronizedAccess.kt create mode 100644 base/src/jsMain/kotlin/de/joshuagleitze/testfiles/internal/JsNodeFileSystem.kt create mode 100644 base/src/jsMain/kotlin/de/joshuagleitze/testfiles/internal/absolutize.kt create mode 100644 base/src/jsMain/kotlin/de/joshuagleitze/testfiles/internal/invalidFileNameCharacters.kt create mode 100644 base/src/jsMain/kotlin/de/joshuagleitze/testfiles/internal/platformFileSystem.kt create mode 100644 base/src/jsMain/kotlin/de/joshuagleitze/testfiles/internal/synchronizedAccess.kt create mode 100644 base/src/jvmMain/kotlin/de/joshuagleitze/testfiles/internal/absolutize.kt create mode 100644 base/src/jvmMain/kotlin/de/joshuagleitze/testfiles/internal/invalidFileNameCharacters.kt create mode 100644 base/src/jvmMain/kotlin/de/joshuagleitze/testfiles/internal/platformFileSystem.kt create mode 100644 base/src/jvmMain/kotlin/de/joshuagleitze/testfiles/internal/synchronizedAccess.kt create mode 100644 base/src/jvmTest/kotlin/de/joshuagleitze/testfiles/DefaultTestFilesSpec.kt create mode 100644 base/src/jvmTest/kotlin/de/joshuagleitze/testfiles/OkioPathAssertions.kt create mode 100644 base/src/jvmTest/kotlin/de/joshuagleitze/testfiles/testFilesFileRoot.kt create mode 100644 kotest/src/commonMain/kotlin/de/joshuagleitze/testfiles/kotest/KotestTestFilesAdapter.kt create mode 100644 kotest/src/commonMain/kotlin/de/joshuagleitze/testfiles/kotest/internal/testScopeName.kt create mode 100644 kotest/src/commonMain/kotlin/de/joshuagleitze/testfiles/kotest/testFiles.kt create mode 100644 kotest/src/jsMain/kotlin/de/joshuagleitze/testfiles/kotest/internal/testScopeName.kt create mode 100644 kotest/src/jsTest/kotlin/de/joshuagleitze/testfiles/kotest/KotestTestFilesIntegrationSpec.kt create mode 100644 kotest/src/jsTest/kotlin/de/joshuagleitze/testfiles/kotest/samples/ExampleSpec.kt create mode 100644 kotest/src/jvmMain/kotlin/de/joshuagleitze/testfiles/kotest/internal/testScopeName.kt create mode 100644 kotest/src/jvmTest/kotlin/de/joshuagleitze/testfiles/kotest/KotestTestFilesIntegrationSpec.kt create mode 100644 kotest/src/jvmTest/kotlin/de/joshuagleitze/testfiles/kotest/samples/ExampleSpec.kt diff --git a/base/build.gradle.kts b/base/build.gradle.kts index 6afa7fa..a8884aa 100644 --- a/base/build.gradle.kts +++ b/base/build.gradle.kts @@ -1,51 +1,51 @@ -import org.gradle.api.JavaVersion.VERSION_1_8 -import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.dokka.gradle.DokkaTask import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { - kotlin("jvm") - id("org.jetbrains.dokka") +kotlin("multiplatform") +id("org.jetbrains.dokka") } val artifactId by extra("testfiles") val description by extra("Manage test files and directories neatly!") -dependencies { - val spekVersion = "2.0.17" - - testImplementation(name = "spek-dsl-jvm", version = spekVersion, group = "org.spekframework.spek2") - testImplementation(name = "atrium-fluent-en_GB", version = "0.16.0", group = "ch.tutteli.atrium") - testRuntimeOnly(name = "spek-runner-junit5", version = spekVersion, group = "org.spekframework.spek2") - - constraints { - testImplementation(kotlin("reflect", version = KotlinCompilerVersion.VERSION)) - } +kotlin { +jvm() +js(IR) { +nodejs() } -java { - sourceCompatibility = VERSION_1_8 - targetCompatibility = VERSION_1_8 +sourceSets { +val commonMain by getting { +dependencies { +api("com.squareup.okio:okio:3.9.0") +} } -kotlin { - explicitApi() +val jvmTest by getting { +dependencies { +val spekVersion = "2.0.17" +implementation("org.spekframework.spek2:spek-dsl-jvm:$spekVersion") +implementation("ch.tutteli.atrium:atrium-fluent-en_GB:0.16.0") +runtimeOnly("org.spekframework.spek2:spek-runner-junit5:$spekVersion") +implementation(kotlin("reflect")) +} +} +} } tasks.withType { - kotlinOptions { - jvmTarget = "1.8" - freeCompilerArgs += "-Xopt-in=kotlin.io.path.ExperimentalPathApi" - } +kotlinOptions { +jvmTarget = "1.8" +} } tasks.withType { - useJUnitPlatform() +useJUnitPlatform() - val testPwd = buildDir.resolve("test-pwd") - doFirst { - testPwd.mkdirs() - } - workingDir = testPwd +val testPwd = buildDir.resolve("test-pwd") +doFirst { +testPwd.mkdirs() +} +workingDir = testPwd } - - diff --git a/base/src/commonMain/kotlin/de/joshuagleitze/testfiles/DefaultTestFiles.kt b/base/src/commonMain/kotlin/de/joshuagleitze/testfiles/DefaultTestFiles.kt new file mode 100644 index 0000000..07e640f --- /dev/null +++ b/base/src/commonMain/kotlin/de/joshuagleitze/testfiles/DefaultTestFiles.kt @@ -0,0 +1,203 @@ +package de.joshuagleitze.testfiles + +import de.joshuagleitze.testfiles.DeletionMode.Always +import de.joshuagleitze.testfiles.DeletionMode.IfSuccessful +import de.joshuagleitze.testfiles.DeletionMode.Never +import de.joshuagleitze.testfiles.internal.absolutize +import de.joshuagleitze.testfiles.internal.invalidFileNameCharacters +import de.joshuagleitze.testfiles.internal.platformFileSystem +import de.joshuagleitze.testfiles.internal.platformTemporaryDirectory +import de.joshuagleitze.testfiles.internal.synchronizedAccess +import okio.IOException +import okio.Path +import okio.Path.Companion.toPath +import kotlin.random.Random + +public class DefaultTestFiles : TestFiles { + private var currentScope = ROOT_SCOPE + + override fun createDirectory(name: String?, delete: DeletionMode): Path = + currentScope.prepareNewPath(name, delete).also { platformFileSystem.createDirectories(it) } + + override fun createFile(name: String?, delete: DeletionMode): Path = + currentScope.prepareNewPath(name, delete).also { platformFileSystem.write(it) {} } + + /** + * Reports that we have entered a new scope. + */ + public fun enterScope(name: String) { + val nextScopeDirectory = currentScope.targetDirectory / "[${escapeScopeName(name)}]" + nextScopeDirectory.clearScopeDirectory(isRoot = true) + currentScope = ScopeFiles(currentScope, nextScopeDirectory) + } + + /** + * Reports that we have left the scope that was entered most recently without being left yet. Also reports that the scope had the + * provided [result]. A [ScopeResult.Failure] will be applied to all currently entered scopes. That means that if any other scope that + * was entered during the current scope reported a [ScopeResult.Failure], this scope will be considered to have failed even if [result] + * is not [ScopeResult.Failure] + */ + public fun leaveScope(result: ScopeResult) { + currentScope.report(result) + currentScope.cleanup() + currentScope = currentScope.parent + } + + private fun escapeScopeName(name: String) = name.replace(invalidFileNameCharacters, "-") + + private class ScopeFiles(parent: ScopeFiles?, val targetDirectory: Path) { + val parent = parent ?: this + private var result: ScopeResult? = null + private val toDelete = HashMap>() + private var created: Boolean = false + private val idGenerator = Random(targetDirectory.hashCode()) + + fun prepareNewPath(name: String?, delete: DeletionMode): Path { + val targetName = name?.apply(Companion::checkFileName) ?: generateTestFileName() + val target = ensureExistingTargetDirectory() / targetName + toDelete.getOrPut(delete) { mutableSetOf() }.add(target) + return target + } + + // double checked locking does not suffer the "not fully initialized object" problem here. + private fun ensureExistingTargetDirectory(): Path { + if (!created) { + synchronizedAccess(this) { + if (!created) { + platformFileSystem.createDirectories(targetDirectory) + created = true + } + } + } + return targetDirectory + } + + private fun requireResult() = result ?: error("No result has been reported for the scope $targetDirectory!") + + fun cleanup() { + synchronizedAccess(this) { + if (created) { + val result = requireResult() + toDelete.forEach { (deletionMode, files) -> + if (result.shouldBeDeleted(deletionMode)) files.forEach { it.clearScopeDirectory(isRoot = true) } + } + try { + platformFileSystem.delete(targetDirectory) + } catch (_: IOException) { + // directory not empty, leave it + } + } + } + } + + fun report(result: ScopeResult) { + this.result = this.result?.combineWith(result) ?: result + if (parent !== this) parent.report(result) + } + + private fun generateTestFileName() = "test-" + idGenerator.nextInt(Int.MAX_VALUE) + } + + public companion object { + private val ROOT_SCOPE by lazy { ScopeFiles(null, determineTestFilesRootDirectory()) } + + /** + * Pattern of directories that are created to group test files by their scope. + */ + public val SCOPE_DIRECTORY_PATTERN: Regex = Regex("^\\[.*]$") + + /** + * Determines the root directory within which all test files will be created. + */ + public fun determineTestFilesRootDirectory(): Path = absolutize(when { + platformFileSystem.metadataOrNull("build".toPath())?.isDirectory == true -> "build/test-outputs".toPath() + platformFileSystem.metadataOrNull("target".toPath())?.isDirectory == true -> "target/test-outputs".toPath() + platformFileSystem.metadataOrNull("test-outputs".toPath())?.isDirectory == true -> "test-outputs".toPath() + else -> platformTemporaryDirectory / "test-outputs" + }) + + private fun checkFileName(name: String) { + require(!name.matches(SCOPE_DIRECTORY_PATTERN)) { + "A test file name must not start with '[' and end with ']'! was: '$name'" + } + } + + private fun Path.clearScopeDirectory(isRoot: Boolean) { + val fs = platformFileSystem + if (!fs.exists(this)) return + val metadata = fs.metadataOrNull(this) ?: return + if (metadata.isRegularFile) { + try { + fs.delete(this) + } catch (_: IOException) { + // swallow + } + return + } + if (metadata.isDirectory) { + try { + fs.list(this).forEach { child -> + val childIsDir = fs.metadataOrNull(child)?.isDirectory == true + val isNestedScope = !isRoot && childIsDir && SCOPE_DIRECTORY_PATTERN.matches(child.name) + if (!isNestedScope) { + child.clearScopeDirectory(isRoot = false) + } + } + } catch (_: IOException) { + // swallow listing errors + } + try { + fs.delete(this) + } catch (_: IOException) { + // directory not empty, leave it + } + } + } + } + + /** + * The outcomes of a test scope that are relevant to us. + * + * This does not include skipped scopes, as they should not be reported to [DefaultTestFiles] in the first place. + */ + public enum class ScopeResult { + /** + * All tests in this scope were successful. + */ + Success { + public override fun combineWith(otherResult: ScopeResult): ScopeResult = when (otherResult) { + Success -> Success + Failure -> Failure + } + + public override fun shouldBeDeleted(deletionMode: DeletionMode): Boolean = when (deletionMode) { + Always, + IfSuccessful -> true + Never -> false + } + }, + + /** + * At least one test in the current scope failed in any way. + */ + Failure { + public override fun combineWith(otherResult: ScopeResult): ScopeResult = Failure + public override fun shouldBeDeleted(deletionMode: DeletionMode): Boolean = when (deletionMode) { + Always -> true + IfSuccessful, Never -> false + } + }; + + /** + * Combines this result with [otherResult], such that if a scope had previously `this` result and [otherResult] occurred in the + * scope, the returned value is the new overall result of the scope. + */ + public abstract fun combineWith(otherResult: ScopeResult): ScopeResult + + /** + * Determines whether, if some file was created with the provided [deletionMode] for a scope that had `this` result, the file should + * now be deleted. + */ + public abstract fun shouldBeDeleted(deletionMode: DeletionMode): Boolean + } +} diff --git a/base/src/commonMain/kotlin/de/joshuagleitze/testfiles/DeletionMode.kt b/base/src/commonMain/kotlin/de/joshuagleitze/testfiles/DeletionMode.kt new file mode 100644 index 0000000..4acdb37 --- /dev/null +++ b/base/src/commonMain/kotlin/de/joshuagleitze/testfiles/DeletionMode.kt @@ -0,0 +1,19 @@ +package de.joshuagleitze.testfiles + +public enum class DeletionMode { + /** + * Always delete the created file after the test has run. + */ + Always, + + /** + * Delete the file if the test ran through successfully; retain the file if the test failed. Retained files will be + * deleted the next time the test is executed. + */ + IfSuccessful, + + /** + * Retain the file after the test ran. Retained files will be deleted the next time the test is executed. + */ + Never +} diff --git a/base/src/commonMain/kotlin/de/joshuagleitze/testfiles/TestFiles.kt b/base/src/commonMain/kotlin/de/joshuagleitze/testfiles/TestFiles.kt new file mode 100644 index 0000000..c81e649 --- /dev/null +++ b/base/src/commonMain/kotlin/de/joshuagleitze/testfiles/TestFiles.kt @@ -0,0 +1,31 @@ +package de.joshuagleitze.testfiles + +import de.joshuagleitze.testfiles.DeletionMode.IfSuccessful +import okio.Path + +/** + * Helper that creates test files and directories for use in tests. The helper manages a directory structure that reflects the structure of + * the tests. Created files and directories will reside in the part of the directory structure that corresponds to the current test scope. + * The helper also manages when to delete the created files. + * + * @see DeletionMode + */ +public interface TestFiles { + /** + * Creates a test directory in the directory of the current test scope. + * + * @param name The name of the directory. If omitted or `null`, the name will be generated. + * @param delete When to delete the created directory, see [DeletionMode]. + * @return The absolute [Path] to the created directory. + */ + public fun createDirectory(name: String? = null, delete: DeletionMode = IfSuccessful): Path + + /** + * Creates a test file in the directory of the current test scope. + * + * @param name The name of the file. If omitted or `null`, the name will be generated. + * @param delete When to delete the created file, see [DeletionMode]. + * @return The absolute [Path] to the created file. + */ + public fun createFile(name: String? = null, delete: DeletionMode = IfSuccessful): Path +} diff --git a/base/src/commonMain/kotlin/de/joshuagleitze/testfiles/internal/absolutize.kt b/base/src/commonMain/kotlin/de/joshuagleitze/testfiles/internal/absolutize.kt new file mode 100644 index 0000000..902773e --- /dev/null +++ b/base/src/commonMain/kotlin/de/joshuagleitze/testfiles/internal/absolutize.kt @@ -0,0 +1,5 @@ +package de.joshuagleitze.testfiles.internal + +import okio.Path + +internal expect fun absolutize(path: Path): Path diff --git a/base/src/commonMain/kotlin/de/joshuagleitze/testfiles/internal/invalidFileNameCharacters.kt b/base/src/commonMain/kotlin/de/joshuagleitze/testfiles/internal/invalidFileNameCharacters.kt new file mode 100644 index 0000000..7a52491 --- /dev/null +++ b/base/src/commonMain/kotlin/de/joshuagleitze/testfiles/internal/invalidFileNameCharacters.kt @@ -0,0 +1,3 @@ +package de.joshuagleitze.testfiles.internal + +internal expect val invalidFileNameCharacters: Regex diff --git a/base/src/commonMain/kotlin/de/joshuagleitze/testfiles/internal/platformFileSystem.kt b/base/src/commonMain/kotlin/de/joshuagleitze/testfiles/internal/platformFileSystem.kt new file mode 100644 index 0000000..df5763d --- /dev/null +++ b/base/src/commonMain/kotlin/de/joshuagleitze/testfiles/internal/platformFileSystem.kt @@ -0,0 +1,7 @@ +package de.joshuagleitze.testfiles.internal + +import okio.FileSystem +import okio.Path + +internal expect val platformFileSystem: FileSystem +internal expect val platformTemporaryDirectory: Path diff --git a/base/src/commonMain/kotlin/de/joshuagleitze/testfiles/internal/synchronizedAccess.kt b/base/src/commonMain/kotlin/de/joshuagleitze/testfiles/internal/synchronizedAccess.kt new file mode 100644 index 0000000..1a5499d --- /dev/null +++ b/base/src/commonMain/kotlin/de/joshuagleitze/testfiles/internal/synchronizedAccess.kt @@ -0,0 +1,3 @@ +package de.joshuagleitze.testfiles.internal + +internal expect inline fun synchronizedAccess(lock: Any, block: () -> T): T diff --git a/base/src/jsMain/kotlin/de/joshuagleitze/testfiles/internal/JsNodeFileSystem.kt b/base/src/jsMain/kotlin/de/joshuagleitze/testfiles/internal/JsNodeFileSystem.kt new file mode 100644 index 0000000..3588738 --- /dev/null +++ b/base/src/jsMain/kotlin/de/joshuagleitze/testfiles/internal/JsNodeFileSystem.kt @@ -0,0 +1,127 @@ +package de.joshuagleitze.testfiles.internal + +import okio.FileHandle +import okio.FileMetadata +import okio.FileSystem +import okio.IOException +import okio.Path +import okio.Path.Companion.toPath +import okio.Sink +import okio.Source +import okio.blackholeSink + +/** + * A minimal [FileSystem] implementation for Kotlin/JS Node.js using the built-in `fs`, `os`, and `path` modules. + * Only the operations needed by [de.joshuagleitze.testfiles.DefaultTestFiles] are implemented. + */ +internal object JsNodeFileSystem : FileSystem() { +override fun canonicalize(path: Path): Path { +val str = path.toString() +return js("require('path').resolve(str)").unsafeCast().toPath() +} + +override fun metadataOrNull(path: Path): FileMetadata? { +val str = path.toString() +return try { +val stats: dynamic = js("require('fs').statSync(str)") +FileMetadata( +isRegularFile = stats.isFile().unsafeCast(), +isDirectory = stats.isDirectory().unsafeCast(), +) +} catch (e: Throwable) { +null +} +} + +override fun list(dir: Path): List { +val str = dir.toString() +return try { +val entries = js("require('fs').readdirSync(str)").unsafeCast>() +entries.map { dir / it } +} catch (e: Throwable) { +throw IOException("Failed to list $dir", e) +} +} + +override fun listOrNull(dir: Path): List? { +val str = dir.toString() +return try { +val entries = js("require('fs').readdirSync(str)").unsafeCast>() +entries.map { dir / it } +} catch (e: Throwable) { +null +} +} + +override fun createDirectory(dir: Path, mustCreate: Boolean) { +val str = dir.toString() +try { +js("require('fs').mkdirSync(str)") +} catch (e: Throwable) { +if (!exists(dir)) throw IOException("Failed to create directory $dir", e) +} +} + +override fun appendingSink(file: Path, mustExist: Boolean): Sink { +throw UnsupportedOperationException("appendingSink is not supported by JsNodeFileSystem") +} + +/** + * Creates an empty file and returns a [blackholeSink] that discards any written data. + * This is sufficient for [de.joshuagleitze.testfiles.DefaultTestFiles] which only creates empty files. + */ +override fun sink(file: Path, mustCreate: Boolean): Sink { +val str = file.toString() +if (mustCreate && exists(file)) throw IOException("$file already exists") +try { +js("require('fs').writeFileSync(str, '')") +} catch (e: Throwable) { +throw IOException("Failed to create file $file", e) +} +return blackholeSink() +} + +override fun delete(path: Path, mustExist: Boolean) { +val meta = metadataOrNull(path) +if (meta == null) { +if (mustExist) throw IOException("$path does not exist") +return +} +val str = path.toString() +try { +if (meta.isDirectory) { +js("require('fs').rmdirSync(str)") +} else { +js("require('fs').unlinkSync(str)") +} +} catch (e: Throwable) { +throw IOException("Failed to delete $path", e) +} +} + +override fun atomicMove(source: Path, target: Path) { +val src = source.toString() +val dst = target.toString() +try { +js("require('fs').renameSync(src, dst)") +} catch (e: Throwable) { +throw IOException("Failed to move $source to $target", e) +} +} + +override fun createSymlink(source: Path, target: Path) { +throw UnsupportedOperationException("Symlinks are not supported by JsNodeFileSystem") +} + +override fun openReadOnly(file: Path): FileHandle = +throw UnsupportedOperationException("openReadOnly is not supported by JsNodeFileSystem") + +override fun openReadWrite(file: Path, mustCreate: Boolean, mustExist: Boolean): FileHandle = +throw UnsupportedOperationException("openReadWrite is not supported by JsNodeFileSystem") + +override fun source(file: Path): Source = +throw UnsupportedOperationException("source is not supported by JsNodeFileSystem") + +val TMPDIR: Path +get() = js("require('os').tmpdir()").unsafeCast().toPath() +} diff --git a/base/src/jsMain/kotlin/de/joshuagleitze/testfiles/internal/absolutize.kt b/base/src/jsMain/kotlin/de/joshuagleitze/testfiles/internal/absolutize.kt new file mode 100644 index 0000000..4b6c841 --- /dev/null +++ b/base/src/jsMain/kotlin/de/joshuagleitze/testfiles/internal/absolutize.kt @@ -0,0 +1,9 @@ +package de.joshuagleitze.testfiles.internal + +import okio.Path +import okio.Path.Companion.toPath + +internal actual fun absolutize(path: Path): Path { +if (path.isAbsolute) return path +return JsNodeFileSystem.canonicalize(path) +} diff --git a/base/src/jsMain/kotlin/de/joshuagleitze/testfiles/internal/invalidFileNameCharacters.kt b/base/src/jsMain/kotlin/de/joshuagleitze/testfiles/internal/invalidFileNameCharacters.kt new file mode 100644 index 0000000..ed9f39c --- /dev/null +++ b/base/src/jsMain/kotlin/de/joshuagleitze/testfiles/internal/invalidFileNameCharacters.kt @@ -0,0 +1,11 @@ +package de.joshuagleitze.testfiles.internal + +private val unixInvalidCharacters = Regex("[/\u0000]") +private val windowsInvalidCharacters = Regex("[/\\\\<>:\"|?*\u0000]") + +// Check process.platform to determine the OS (Node.js environment) +@Suppress("UNUSED_VARIABLE") +private external val process: dynamic + +internal actual val invalidFileNameCharacters: Regex +get() = if (js("process.platform") == "win32") windowsInvalidCharacters else unixInvalidCharacters diff --git a/base/src/jsMain/kotlin/de/joshuagleitze/testfiles/internal/platformFileSystem.kt b/base/src/jsMain/kotlin/de/joshuagleitze/testfiles/internal/platformFileSystem.kt new file mode 100644 index 0000000..ec7f743 --- /dev/null +++ b/base/src/jsMain/kotlin/de/joshuagleitze/testfiles/internal/platformFileSystem.kt @@ -0,0 +1,7 @@ +package de.joshuagleitze.testfiles.internal + +import okio.FileSystem +import okio.Path + +internal actual val platformFileSystem: FileSystem get() = JsNodeFileSystem +internal actual val platformTemporaryDirectory: Path get() = JsNodeFileSystem.TMPDIR diff --git a/base/src/jsMain/kotlin/de/joshuagleitze/testfiles/internal/synchronizedAccess.kt b/base/src/jsMain/kotlin/de/joshuagleitze/testfiles/internal/synchronizedAccess.kt new file mode 100644 index 0000000..49f8243 --- /dev/null +++ b/base/src/jsMain/kotlin/de/joshuagleitze/testfiles/internal/synchronizedAccess.kt @@ -0,0 +1,4 @@ +package de.joshuagleitze.testfiles.internal + +// JavaScript is single-threaded, so no synchronization is needed. +internal actual inline fun synchronizedAccess(lock: Any, block: () -> T): T = block() diff --git a/base/src/jvmMain/kotlin/de/joshuagleitze/testfiles/internal/absolutize.kt b/base/src/jvmMain/kotlin/de/joshuagleitze/testfiles/internal/absolutize.kt new file mode 100644 index 0000000..aa56295 --- /dev/null +++ b/base/src/jvmMain/kotlin/de/joshuagleitze/testfiles/internal/absolutize.kt @@ -0,0 +1,9 @@ +package de.joshuagleitze.testfiles.internal + +import okio.Path +import okio.Path.Companion.toPath + +internal actual fun absolutize(path: Path): Path { +if (path.isAbsolute) return path +return java.nio.file.Paths.get(path.toString()).toAbsolutePath().toString().toPath() +} diff --git a/base/src/jvmMain/kotlin/de/joshuagleitze/testfiles/internal/invalidFileNameCharacters.kt b/base/src/jvmMain/kotlin/de/joshuagleitze/testfiles/internal/invalidFileNameCharacters.kt new file mode 100644 index 0000000..6d421d9 --- /dev/null +++ b/base/src/jvmMain/kotlin/de/joshuagleitze/testfiles/internal/invalidFileNameCharacters.kt @@ -0,0 +1,10 @@ +package de.joshuagleitze.testfiles.internal + +private val unixInvalidCharacters get() = Regex("[/\u0000]") +private val windowsInvalidCharacters get() = Regex("[/\\\\<>:\"|?*\u0000]") + +internal actual val invalidFileNameCharacters: Regex by lazy { + val osName = System.getProperty("os.name").lowercase() + if (setOf("nix", "nux", "aix", "mac").any { osName.contains(it) }) unixInvalidCharacters + else windowsInvalidCharacters // default to windows because it is the most restrictive +} diff --git a/base/src/jvmMain/kotlin/de/joshuagleitze/testfiles/internal/platformFileSystem.kt b/base/src/jvmMain/kotlin/de/joshuagleitze/testfiles/internal/platformFileSystem.kt new file mode 100644 index 0000000..abce078 --- /dev/null +++ b/base/src/jvmMain/kotlin/de/joshuagleitze/testfiles/internal/platformFileSystem.kt @@ -0,0 +1,7 @@ +package de.joshuagleitze.testfiles.internal + +import okio.FileSystem +import okio.Path + +internal actual val platformFileSystem: FileSystem get() = FileSystem.SYSTEM +internal actual val platformTemporaryDirectory: Path get() = FileSystem.SYSTEM_TEMPORARY_DIRECTORY diff --git a/base/src/jvmMain/kotlin/de/joshuagleitze/testfiles/internal/synchronizedAccess.kt b/base/src/jvmMain/kotlin/de/joshuagleitze/testfiles/internal/synchronizedAccess.kt new file mode 100644 index 0000000..377037e --- /dev/null +++ b/base/src/jvmMain/kotlin/de/joshuagleitze/testfiles/internal/synchronizedAccess.kt @@ -0,0 +1,3 @@ +package de.joshuagleitze.testfiles.internal + +internal actual inline fun synchronizedAccess(lock: Any, block: () -> T): T = synchronized(lock, block) diff --git a/base/src/jvmTest/kotlin/de/joshuagleitze/testfiles/DefaultTestFilesSpec.kt b/base/src/jvmTest/kotlin/de/joshuagleitze/testfiles/DefaultTestFilesSpec.kt new file mode 100644 index 0000000..4b4d207 --- /dev/null +++ b/base/src/jvmTest/kotlin/de/joshuagleitze/testfiles/DefaultTestFilesSpec.kt @@ -0,0 +1,336 @@ +package de.joshuagleitze.testfiles + +import ch.tutteli.atrium.api.fluent.en_GB.and +import ch.tutteli.atrium.api.fluent.en_GB.exists +import ch.tutteli.atrium.api.fluent.en_GB.existsNot +import ch.tutteli.atrium.api.fluent.en_GB.isDirectory +import ch.tutteli.atrium.api.fluent.en_GB.isRegularFile +import ch.tutteli.atrium.api.fluent.en_GB.matches +import ch.tutteli.atrium.api.fluent.en_GB.messageContains +import ch.tutteli.atrium.api.fluent.en_GB.notToBe +import ch.tutteli.atrium.api.fluent.en_GB.notToThrow +import ch.tutteli.atrium.api.fluent.en_GB.startsWith +import ch.tutteli.atrium.api.fluent.en_GB.toBe +import ch.tutteli.atrium.api.fluent.en_GB.toThrow +import ch.tutteli.atrium.api.verbs.expect +import de.joshuagleitze.testfiles.DefaultTestFiles.Companion.determineTestFilesRootDirectory +import de.joshuagleitze.testfiles.DefaultTestFiles.ScopeResult.Failure +import de.joshuagleitze.testfiles.DefaultTestFiles.ScopeResult.Success +import de.joshuagleitze.testfiles.DeletionMode.Always +import de.joshuagleitze.testfiles.DeletionMode.IfSuccessful +import de.joshuagleitze.testfiles.DeletionMode.Never +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toPath +import org.spekframework.spek2.Spek +import org.spekframework.spek2.style.specification.describe + +object DefaultTestFilesSpec : Spek({ + val fileRoot = freezeFileRoot() + lateinit var testFiles: DefaultTestFiles + + beforeEachTest { + testFiles = DefaultTestFiles() + } + + describe("DefaultTestFiles") { + describe("root folder") { + beforeEachTest { deletePotentialTargetDirectories() } + + it("uses the build directory if present") { + FileSystem.SYSTEM.createDirectories(buildDir) + FileSystem.SYSTEM.createDirectories(targetDir) + FileSystem.SYSTEM.createDirectories(testOutputsDir) + expect(determineTestFilesRootDirectory()).toBe(buildDir / "test-outputs") + } + + it("uses the target directory if present") { + FileSystem.SYSTEM.createDirectories(targetDir) + FileSystem.SYSTEM.createDirectories(testOutputsDir) + expect(determineTestFilesRootDirectory()).toBe(targetDir / "test-outputs") + } + + it("uses the test-outputs directory if present") { + FileSystem.SYSTEM.createDirectories(testOutputsDir) + expect(determineTestFilesRootDirectory()).toBe(testOutputsDir) + } + + it("falls back to the tmpdir") { + expect(determineTestFilesRootDirectory()).toBe(tmpDir / "test-outputs") + } + } + + describe("housekeeping") { + it("clears pre-existing files when entering a scope") { + val scopeDir = fileRoot / "[delete pre-existing group]" + FileSystem.SYSTEM.createDirectories(scopeDir) + val testDir = scopeDir / "pre-existing dir" + FileSystem.SYSTEM.createDirectory(testDir) + val subTestFile = testDir / "sub" + FileSystem.SYSTEM.write(subTestFile) {} + val testFile = scopeDir / "pre-existing file" + FileSystem.SYSTEM.write(testFile) {} + + testFiles.enterScope("delete pre-existing group") + + expect(testDir.toNioPath()).existsNot() + expect(subTestFile.toNioPath()).existsNot() + expect(testFile.toNioPath()).existsNot() + } + + it("retains existing scope directories when entering a scope") { + val scopeDir = fileRoot / "[retain pre-existing group]" + FileSystem.SYSTEM.createDirectories(scopeDir) + val groupTestDir = scopeDir / "[test]" + FileSystem.SYSTEM.createDirectory(groupTestDir) + val subTestFile = groupTestDir / "deeper pre-existing file" + FileSystem.SYSTEM.write(subTestFile) {} + val testFile = scopeDir / "pre-existing file" + FileSystem.SYSTEM.write(testFile) {} + + testFiles.enterScope("retain pre-existing group") + + expect(scopeDir.toNioPath()).isDirectory() + expect(groupTestDir.toNioPath()).isDirectory() + expect(subTestFile.toNioPath()).isRegularFile() + expect(testFile.toNioPath()).existsNot() + } + + it("does not create a scope folder if not necessary") { + val outerScopeTarget = fileRoot / "[no premature creation]" + val innerScopeTarget = outerScopeTarget / "[sub]" + + testFiles.enterScope("no premature creation") + expect(outerScopeTarget.toNioPath()).existsNot() + + testFiles.enterScope("sub") + expect(outerScopeTarget.toNioPath()).existsNot() + expect(innerScopeTarget.toNioPath()).existsNot() + + testFiles.createFile() + expect(innerScopeTarget.toNioPath()).exists() + } + } + + describe("file name checks") { + it("rejects file names that match the group directory pattern") { + testFiles.enterScope("rejects bad file names") + + expect { testFiles.createFile("[test") }.notToThrow() + expect { testFiles.createFile("test]") }.notToThrow() + expect { testFiles.createFile("[test]") }.toThrow { + messageContains("[test]") + } + } + + it("rejects directory names that match the group directory pattern") { + testFiles.enterScope("rejects bad directory names") + + expect { testFiles.createDirectory("[test") }.notToThrow() + expect { testFiles.createDirectory("test]") }.notToThrow() + expect { testFiles.createDirectory("[test]") }.toThrow { + messageContains("[test]") + } + } + + listOf('/', '\\', '<', '>', ':', '\"', '|', '?', '*', '\u0000').forEach { badCharacter -> + it("escapes '$badCharacter' in a scope name if necessary") { + testFiles.enterScope("test with -$badCharacter- in it") + + expect { testFiles.createFile("test") }.notToThrow() + // check that / \ is not messing up the directory structure + .and.parent.fileName.matches(Regex(".test with -.- in it.")) + } + } + } + } + + describe("file creation") { + it("creates an empty file with the provided name") { + testFiles.enterScope("named file creation") + expect(testFiles.createFile("testfile")) { + isRegularFile() + isReadable() + isWritable() + content.toBe("") + fileName.toBe("testfile") + parent.toBe(fileRoot / "[named file creation]") + } + + testFiles.enterScope("inner") + expect(testFiles.createFile("testfile")) { + isRegularFile() + isReadable() + isWritable() + content.toBe("") + fileName.toBe("testfile") + parent.toBe(fileRoot / "[named file creation]" / "[inner]") + } + } + + it("creates an empty directory with the provided name") { + testFiles.enterScope("named directory creation") + expect(testFiles.createDirectory("testdir")) { + isDirectory() + isReadable() + isWritable() + fileName.toBe("testdir") + parent.toBe(fileRoot / "[named directory creation]") + } + + testFiles.enterScope("inner") + expect(testFiles.createDirectory("testdir")) { + isDirectory() + isReadable() + isWritable() + fileName.toBe("testdir") + parent.toBe(fileRoot / "[named directory creation]" / "[inner]") + } + } + + it("hands out absolute paths") { + testFiles.enterScope("hands out absolute paths") + + expect(testFiles.createFile("testFile")).isAbsolute() + expect(testFiles.createDirectory("testDir")).isAbsolute() + } + + it("creates an empty file with a generated name") { + testFiles.enterScope("unnamed file creation") + expect(testFiles.createFile()) { + isRegularFile() + isReadable() + isWritable() + content.toBe("") + fileName.startsWith("test-") + parent.toBe(fileRoot / "[unnamed file creation]") + } + + testFiles.enterScope("inner") + expect(testFiles.createFile()) { + isRegularFile() + isReadable() + isWritable() + content.toBe("") + fileName.startsWith("test-") + parent.toBe(fileRoot / "[unnamed file creation]" / "[inner]") + } + } + + it("creates an empty directory with a generated name") { + testFiles.enterScope("unnamed directory creation") + expect(testFiles.createDirectory()) { + isDirectory() + isReadable() + isWritable() + fileName.startsWith("test-") + parent.toBe(fileRoot / "[unnamed directory creation]") + } + + testFiles.enterScope("inner") + expect(testFiles.createDirectory()) { + isDirectory() + isReadable() + isWritable() + fileName.startsWith("test-") + parent.toBe(fileRoot / "[unnamed directory creation]" / "[inner]") + } + } + + it("generates different file names on subsequent creations") { + testFiles.enterScope("different file names") + + expect(testFiles.createFile()).notToBe(testFiles.createFile()) + expect(testFiles.createDirectory()).notToBe(testFiles.createDirectory()) + } + + it("generates the same file names for the same creations") { + testFiles.enterScope("consistency") + val firstFileFirstTime = testFiles.createFile() + val secondFileFirstTime = testFiles.createFile() + val thirdFileFirstTime = testFiles.createFile() + testFiles.leaveScope(Success) + + testFiles.enterScope("consistency") + val firstFileSecondTime = testFiles.createFile() + val secondFileSecondTime = testFiles.createFile() + val thirdFileSecondTime = testFiles.createFile() + testFiles.leaveScope(Success) + + expect(firstFileFirstTime).toBe(firstFileSecondTime) + expect(secondFileFirstTime).toBe(secondFileSecondTime) + expect(thirdFileFirstTime).toBe(thirdFileSecondTime) + } + } + + describe("file cleanup") { + listOf( + Always to Success, + Always to Failure, + IfSuccessful to Success + ).forEach { (deletionMode, result) -> + it("deletes a file that has been marked to be deleted $deletionMode after $result") { + testFiles.enterScope("delete after $result") + val testfile = testFiles.createFile(delete = deletionMode) + val testdir = testFiles.createDirectory(delete = deletionMode) + expect(testfile.toNioPath()).exists() + expect(testdir.toNioPath()).exists() + + testFiles.leaveScope(result) + expect(testfile.toNioPath()).existsNot() + expect(testdir.toNioPath()).existsNot() + } + } + + listOf( + IfSuccessful to Failure, + Never to Success, + Never to Failure + ).forEach { (deletionMode, result) -> + it("retains a file that has been marked to be deleted $deletionMode after $result") { + testFiles.enterScope("retain after $result") + val testfile = testFiles.createFile(delete = deletionMode) + val testdir = testFiles.createDirectory(delete = deletionMode) + expect(testfile.toNioPath()).exists() + expect(testdir.toNioPath()).exists() + + testFiles.leaveScope(result) + expect(testfile.toNioPath()).exists() + expect(testdir.toNioPath()).exists() + } + } + + it("retains a file that has been marked to be deleted IF_SUCCESSFUL if only one inner scope reported FAILURE") { + testFiles.enterScope("outer") + val testfile = testFiles.createFile(delete = IfSuccessful) + val testdir = testFiles.createDirectory(delete = IfSuccessful) + + testFiles.enterScope("first inner (successful)") + testFiles.createFile(delete = IfSuccessful) + testFiles.leaveScope(Success) + + testFiles.enterScope("second inner (failing)") + testFiles.createFile(delete = IfSuccessful) + testFiles.leaveScope(Failure) + + testFiles.enterScope("third inner (successful)") + testFiles.createFile(delete = IfSuccessful) + testFiles.leaveScope(Success) + + testFiles.leaveScope(Success) + + expect(testfile.toNioPath()).exists() + expect(testdir.toNioPath()).exists() + } + + it("tolerates deletion of created files") { + testFiles.enterScope("tolerate deletion") + FileSystem.SYSTEM.delete(testFiles.createFile(delete = Always)) + FileSystem.SYSTEM.delete(testFiles.createDirectory(delete = Always)) + + expect { + testFiles.leaveScope(Success) + }.notToThrow() + } + } +}) diff --git a/base/src/jvmTest/kotlin/de/joshuagleitze/testfiles/OkioPathAssertions.kt b/base/src/jvmTest/kotlin/de/joshuagleitze/testfiles/OkioPathAssertions.kt new file mode 100644 index 0000000..29ca221 --- /dev/null +++ b/base/src/jvmTest/kotlin/de/joshuagleitze/testfiles/OkioPathAssertions.kt @@ -0,0 +1,25 @@ +package de.joshuagleitze.testfiles + +import ch.tutteli.atrium.api.fluent.en_GB.feature +import ch.tutteli.atrium.api.fluent.en_GB.isDirectory +import ch.tutteli.atrium.api.fluent.en_GB.isReadable +import ch.tutteli.atrium.api.fluent.en_GB.isRegularFile +import ch.tutteli.atrium.api.fluent.en_GB.isWritable +import ch.tutteli.atrium.api.fluent.en_GB.toBe +import ch.tutteli.atrium.creating.Expect +import okio.Path + +val Expect.content + get() = feature("content") { toNioPath().toFile().readText() } + +val Expect.fileName + get() = feature("name") { name } + +val Expect.parent + get() = feature("parent") { parent!! } + +fun Expect.isRegularFile() = feature("as NIO path") { toNioPath() }.isRegularFile() +fun Expect.isDirectory() = feature("as NIO path") { toNioPath() }.isDirectory() +fun Expect.isReadable() = feature("as NIO path") { toNioPath() }.isReadable() +fun Expect.isWritable() = feature("as NIO path") { toNioPath() }.isWritable() +fun Expect.isAbsolute() = feature("isAbsolute") { isAbsolute }.toBe(true) diff --git a/base/src/jvmTest/kotlin/de/joshuagleitze/testfiles/testFilesFileRoot.kt b/base/src/jvmTest/kotlin/de/joshuagleitze/testfiles/testFilesFileRoot.kt new file mode 100644 index 0000000..8346f97 --- /dev/null +++ b/base/src/jvmTest/kotlin/de/joshuagleitze/testfiles/testFilesFileRoot.kt @@ -0,0 +1,42 @@ +package de.joshuagleitze.testfiles + +import de.joshuagleitze.testfiles.DefaultTestFiles.Companion.determineTestFilesRootDirectory +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toPath +import java.nio.file.Files.walk +import java.util.stream.Stream +import kotlin.io.path.deleteExisting + +private fun String.toAbsoluteOkioPath() = java.nio.file.Paths.get(this).toAbsolutePath().toString().toPath() + +val buildDir = "build".toAbsoluteOkioPath() +val targetDir = "target".toAbsoluteOkioPath() +val testOutputsDir = "test-outputs".toAbsoluteOkioPath() +val tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY + +fun deletePotentialTargetDirectories() { +listOf(buildDir, targetDir, testOutputsDir).forEach { it.deleteRecursivelyIfExists() } +determineTestFilesRootDirectory().deleteRecursivelyIfExists() +} + +private fun Path.deleteRecursivelyIfExists() { +val nioPath = java.nio.file.Paths.get(toString()) +walkNioIfExists(nioPath).sorted(reverseOrder()).forEach { it.deleteExisting() } +} + +private fun walkNioIfExists(path: java.nio.file.Path) = try { +walk(path) +} catch (e: java.nio.file.NoSuchFileException) { +Stream.empty() +} + +private val fileRoot by lazy { +deletePotentialTargetDirectories() +FileSystem.SYSTEM.createDirectories(testOutputsDir) +val fileRoot = determineTestFilesRootDirectory() +DefaultTestFiles() // call constructor to freeze output directory +fileRoot +} + +fun freezeFileRoot() = fileRoot diff --git a/build.gradle.kts b/build.gradle.kts index fac879f..357b6cd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,13 +1,15 @@ import org.jetbrains.dokka.gradle.DokkaTask +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension plugins { - kotlin("jvm") version "1.9.23" - id("org.jetbrains.dokka") version "1.9.20" - id("com.palantir.git-version") version "3.0.0" - `maven-publish` - signing - id("io.github.gradle-nexus.publish-plugin") version "1.3.0" - idea +kotlin("jvm") version "1.9.23" +kotlin("multiplatform") version "1.9.23" apply false +id("org.jetbrains.dokka") version "1.9.20" +id("com.palantir.git-version") version "3.0.0" +`maven-publish` +signing +id("io.github.gradle-nexus.publish-plugin") version "1.3.0" +idea } group = "de.joshuagleitze" @@ -16,26 +18,26 @@ status = if (isSnapshot) "snapshot" else "release" val gitRef = if (isSnapshot) versionDetails.gitHash else versionDetails.lastTag subprojects { - group = rootProject.group - version = rootProject.version - status = rootProject.status +group = rootProject.group +version = rootProject.version +status = rootProject.status } allprojects { - plugins.apply("org.gradle.idea") - repositories { - mavenCentral() - } - idea { - module { - isDownloadJavadoc = true - isDownloadSources = true - } - } +plugins.apply("org.gradle.idea") +repositories { +mavenCentral() +} +idea { +module { +isDownloadJavadoc = true +isDownloadSources = true +} +} } tasks.withType { - reports.junitXml.required.set(true) +reports.junitXml.required.set(true) } val ossrhUsername: String? by project @@ -45,117 +47,186 @@ val githubOwner = githubRepository?.split("/")?.get(0) val githubToken: String? by project val mavenCentral = nexusPublishing.repositories.sonatype { - username.set(ossrhUsername) - password.set(ossrhPassword) +username.set(ossrhUsername) +password.set(ossrhPassword) } subprojects { - afterEvaluate { - apply { - plugin("org.jetbrains.dokka") - plugin("org.gradle.maven-publish") - plugin("org.gradle.signing") - } - - val sourcesJar by tasks.registering(Jar::class) { - group = "build" - description = "Assembles the source code into a jar" - archiveClassifier.set("sources") - from(sourceSets.main.map { it.allSource }) - } - - tasks.withType { - dokkaSourceSets.named("main") { - this.DokkaSourceSetID(if (extra.has("artifactId")) extra["artifactId"] as String else project.name) - sourceLink { - val projectPath = projectDir.absoluteFile.relativeTo(rootProject.projectDir.absoluteFile) - localDirectory.set(file("src/main/kotlin")) - remoteUrl.set(uri("https://github.com/$githubRepository/blob/$gitRef/$projectPath/src/main/kotlin").toURL()) - remoteLineSuffix.set("#L") - } - } - } - - val dokkaJar by tasks.registering(Jar::class) { - group = "build" - description = "Assembles the Kotlin docs with Dokka" - archiveClassifier.set("javadoc") - from(tasks.named("dokkaJavadoc")) - } - - artifacts { - archives(sourcesJar) - archives(dokkaJar) - } - - signing { - val signingKey: String? by project - val signingKeyPassword: String? by project - useInMemoryPgpKeys(signingKey, signingKeyPassword) - } - - publishing.publications.create("maven") { - artifactId = if (extra.has("artifactId")) extra["artifactId"] as String else project.name - - from(components["java"]) - artifact(sourcesJar) - artifact(dokkaJar) - - signing.sign(this) - - pom { - name.set("$groupId:$artifactId") - if (extra.has("description")) description.set(extra["description"] as String) - inceptionYear.set("2020") - url.set("https://github.com/$githubRepository") - ciManagement { - system.set("GitHub Actions") - url.set("https://github.com/$githubRepository/actions") - } - issueManagement { - system.set("GitHub Issues") - url.set("https://github.com/$githubRepository/issues") - } - developers { - developer { - name.set("Joshua Gleitze") - email.set("dev@joshuagleitze.de") - } - } - scm { - connection.set("scm:git:https://github.com/$githubRepository.git") - developerConnection.set("scm:git:git://git@github.com:$githubRepository.git") - url.set("https://github.com/$githubRepository") - } - licenses { - license { - name.set("MIT") - url.set("https://opensource.org/licenses/MIT") - distribution.set("repo") - } - } - } - } - - val githubPackages = publishing.repositories.maven("https://maven.pkg.github.com/$githubRepository") { - name = "GitHubPackages" - credentials { - username = githubOwner - password = githubToken - } - } - - val publishToGithub = tasks.named("publishAllPublicationsTo${githubPackages.name.firstUpper()}Repository") - val publishToMavenCentral = tasks.named("publishTo${mavenCentral.name.firstUpper()}") - - tasks.register("release") { - group = "release" - description = "Releases the project to all remote repositories" - dependsOn(publishToGithub, publishToMavenCentral, rootProject.tasks.closeAndReleaseStagingRepository) - } - - rootProject.tasks.closeAndReleaseStagingRepository { mustRunAfter(publishToMavenCentral) } - } +afterEvaluate { +apply { +plugin("org.jetbrains.dokka") +plugin("org.gradle.maven-publish") +plugin("org.gradle.signing") +} + +val isMultiplatform = plugins.hasPlugin("org.jetbrains.kotlin.multiplatform") + +val dokkaJar by tasks.registering(Jar::class) { +group = "build" +description = "Assembles the Kotlin docs with Dokka" +archiveClassifier.set("javadoc") +from(tasks.named("dokkaJavadoc")) +} + +signing { +val signingKey: String? by project +val signingKeyPassword: String? by project +useInMemoryPgpKeys(signingKey, signingKeyPassword) +} + +if (isMultiplatform) { +val kotlin = extensions.getByType() + +tasks.withType { +dokkaSourceSets.named("commonMain") { +this.DokkaSourceSetID(if (extra.has("artifactId")) extra["artifactId"] as String else project.name) +sourceLink { +val projectPath = projectDir.absoluteFile.relativeTo(rootProject.projectDir.absoluteFile) +localDirectory.set(file("src/commonMain/kotlin")) +remoteUrl.set(uri("https://github.com/$githubRepository/blob/$gitRef/$projectPath/src/commonMain/kotlin").toURL()) +remoteLineSuffix.set("#L") +} +} +} + +// KMP plugin creates publications for each target; configure POM metadata on all of them +publishing.publications.withType().configureEach { +val targetArtifactId = if (extra.has("artifactId")) extra["artifactId"] as String else project.name +// KMP root publication uses the base artifactId; platform publications get a suffix +if (name == "kotlinMultiplatform") { +artifactId = targetArtifactId +artifact(dokkaJar) +} else if (name == "jvm") { +artifactId = "$targetArtifactId-jvm" +} else if (name == "js") { +artifactId = "$targetArtifactId-js" +} else if (name == "metadata") { +artifactId = "$targetArtifactId-metadata" +} + +signing.sign(this) + +pom { +name.set("$groupId:$artifactId") +if (extra.has("description")) description.set(extra["description"] as String) +inceptionYear.set("2020") +url.set("https://github.com/$githubRepository") +ciManagement { +system.set("GitHub Actions") +url.set("https://github.com/$githubRepository/actions") +} +issueManagement { +system.set("GitHub Issues") +url.set("https://github.com/$githubRepository/issues") +} +developers { +developer { +name.set("Joshua Gleitze") +email.set("dev@joshuagleitze.de") +} +} +scm { +connection.set("scm:git:https://github.com/$githubRepository.git") +developerConnection.set("scm:git:git://git@github.com:$githubRepository.git") +url.set("https://github.com/$githubRepository") +} +licenses { +license { +name.set("MIT") +url.set("https://opensource.org/licenses/MIT") +distribution.set("repo") +} +} +} +} +} else { +val sourcesJar by tasks.registering(Jar::class) { +group = "build" +description = "Assembles the source code into a jar" +archiveClassifier.set("sources") +from(sourceSets.main.map { it.allSource }) +} + +tasks.withType { +dokkaSourceSets.named("main") { +this.DokkaSourceSetID(if (extra.has("artifactId")) extra["artifactId"] as String else project.name) +sourceLink { +val projectPath = projectDir.absoluteFile.relativeTo(rootProject.projectDir.absoluteFile) +localDirectory.set(file("src/main/kotlin")) +remoteUrl.set(uri("https://github.com/$githubRepository/blob/$gitRef/$projectPath/src/main/kotlin").toURL()) +remoteLineSuffix.set("#L") +} +} +} + +artifacts { +archives(sourcesJar) +archives(dokkaJar) +} + +publishing.publications.create("maven") { +artifactId = if (extra.has("artifactId")) extra["artifactId"] as String else project.name + +from(components["java"]) +artifact(sourcesJar) +artifact(dokkaJar) + +signing.sign(this) + +pom { +name.set("$groupId:$artifactId") +if (extra.has("description")) description.set(extra["description"] as String) +inceptionYear.set("2020") +url.set("https://github.com/$githubRepository") +ciManagement { +system.set("GitHub Actions") +url.set("https://github.com/$githubRepository/actions") +} +issueManagement { +system.set("GitHub Issues") +url.set("https://github.com/$githubRepository/issues") +} +developers { +developer { +name.set("Joshua Gleitze") +email.set("dev@joshuagleitze.de") +} +} +scm { +connection.set("scm:git:https://github.com/$githubRepository.git") +developerConnection.set("scm:git:git://git@github.com:$githubRepository.git") +url.set("https://github.com/$githubRepository") +} +licenses { +license { +name.set("MIT") +url.set("https://opensource.org/licenses/MIT") +distribution.set("repo") +} +} +} +} +} + +val githubPackages = publishing.repositories.maven("https://maven.pkg.github.com/$githubRepository") { +name = "GitHubPackages" +credentials { +username = githubOwner +password = githubToken +} +} + +val publishToGithub = tasks.named("publishAllPublicationsTo${githubPackages.name.firstUpper()}Repository") +val publishToMavenCentral = tasks.named("publishTo${mavenCentral.name.firstUpper()}") + +tasks.register("release") { +group = "release" +description = "Releases the project to all remote repositories" +dependsOn(publishToGithub, publishToMavenCentral, rootProject.tasks.closeAndReleaseStagingRepository) +} + +rootProject.tasks.closeAndReleaseStagingRepository { mustRunAfter(publishToMavenCentral) } +} } val Project.isSnapshot get() = versionDetails.commitDistance != 0 diff --git a/kotest/build.gradle.kts b/kotest/build.gradle.kts index ed62857..ad9e8e5 100644 --- a/kotest/build.gradle.kts +++ b/kotest/build.gradle.kts @@ -1,59 +1,54 @@ -import org.gradle.api.JavaVersion.VERSION_1_8 -import org.jetbrains.dokka.gradle.DokkaTask -import org.jetbrains.kotlin.config.KotlinCompilerVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { - kotlin("jvm") - id("org.jetbrains.dokka") +kotlin("multiplatform") +id("org.jetbrains.dokka") } val artifactId by extra("kotest-files") val description by extra("Manage test files and directories neatly when testing with Kotest!") -dependencies { - val kotestVersion = "4.6.2" - - api(project(":base")) - // Kotest is a peer dependency - compileOnly(name = "kotest-framework-api", version = kotestVersion, group = "io.kotest") - - testImplementation(name = "kotest-runner-junit5", version = kotestVersion, group = "io.kotest") - testImplementation(name = "atrium-fluent-en_GB", version = "0.16.0", group = "ch.tutteli.atrium") - - constraints { - testImplementation(kotlin("reflect", version = KotlinCompilerVersion.VERSION)) - } +kotlin { +jvm() +js(IR) { +nodejs() } -java { - sourceCompatibility = VERSION_1_8 - targetCompatibility = VERSION_1_8 +sourceSets { +val commonMain by getting { +dependencies { +val kotestVersion = "4.6.2" +api(project(":base")) +// Kotest is a peer dependency +compileOnly("io.kotest:kotest-framework-api:$kotestVersion") } - -kotlin { - explicitApi() } -tasks.withType { - kotlinOptions { - jvmTarget = "1.8" - } +val jvmTest by getting { +dependencies { +val kotestVersion = "4.6.2" +implementation("io.kotest:kotest-runner-junit5:$kotestVersion") +implementation("ch.tutteli.atrium:atrium-fluent-en_GB:0.16.0") +implementation(kotlin("reflect")) +} } -tasks.compileTestKotlin { - kotlinOptions { - freeCompilerArgs += "-Xopt-in=kotlin.io.path.ExperimentalPathApi" - } +val jsTest by getting { +dependencies { +val kotestVersion = "4.6.2" +implementation("io.kotest:kotest-framework-engine:$kotestVersion") +implementation("io.kotest:kotest-assertions-core:$kotestVersion") +} +} +} } -tasks.withType { - dokkaSourceSets.named("main") { - samples.from("src/test/kotlin/samples/ExampleSpek.kt") - } +tasks.withType { +kotlinOptions { +jvmTarget = "1.8" +} } tasks.withType { - useJUnitPlatform() +useJUnitPlatform() } - diff --git a/kotest/src/commonMain/kotlin/de/joshuagleitze/testfiles/kotest/KotestTestFilesAdapter.kt b/kotest/src/commonMain/kotlin/de/joshuagleitze/testfiles/kotest/KotestTestFilesAdapter.kt new file mode 100644 index 0000000..15bc72e --- /dev/null +++ b/kotest/src/commonMain/kotlin/de/joshuagleitze/testfiles/kotest/KotestTestFilesAdapter.kt @@ -0,0 +1,41 @@ +package de.joshuagleitze.testfiles.kotest + +import de.joshuagleitze.testfiles.DefaultTestFiles +import de.joshuagleitze.testfiles.kotest.internal.testScopeName +import io.kotest.core.listeners.TestListener +import io.kotest.core.spec.AutoScan +import io.kotest.core.spec.Spec +import io.kotest.core.test.TestCase +import io.kotest.core.test.TestResult +import io.kotest.core.test.TestStatus.Error +import io.kotest.core.test.TestStatus.Failure +import io.kotest.core.test.TestStatus.Ignored +import io.kotest.core.test.TestStatus.Success +import kotlin.reflect.KClass + +@AutoScan +internal object KotestTestFilesAdapter : TestListener { + override val name: String get() = "testfiles" + + override suspend fun prepareSpec(kclass: KClass) { + internalTestFiles.enterScope(kclass.testScopeName()) + } + + override suspend fun finalizeSpec(kclass: KClass, results: Map) { + internalTestFiles.leaveScope(results.values.map { convert(it) }.reduce { left, right -> left.combineWith(right) }) + } + + override suspend fun beforeAny(testCase: TestCase) { + internalTestFiles.enterScope(testCase.displayName) + } + + override suspend fun afterAny(testCase: TestCase, result: TestResult) { + internalTestFiles.leaveScope(convert(result)) + } + + private fun convert(result: TestResult) = when (result.status) { + Success -> DefaultTestFiles.ScopeResult.Success + Error, Failure -> DefaultTestFiles.ScopeResult.Failure + Ignored -> error("contact breach: kotest should not have called us!") + } +} diff --git a/kotest/src/commonMain/kotlin/de/joshuagleitze/testfiles/kotest/internal/testScopeName.kt b/kotest/src/commonMain/kotlin/de/joshuagleitze/testfiles/kotest/internal/testScopeName.kt new file mode 100644 index 0000000..f2ebaea --- /dev/null +++ b/kotest/src/commonMain/kotlin/de/joshuagleitze/testfiles/kotest/internal/testScopeName.kt @@ -0,0 +1,5 @@ +package de.joshuagleitze.testfiles.kotest.internal + +import kotlin.reflect.KClass + +internal expect fun KClass<*>.testScopeName(): String diff --git a/kotest/src/commonMain/kotlin/de/joshuagleitze/testfiles/kotest/testFiles.kt b/kotest/src/commonMain/kotlin/de/joshuagleitze/testfiles/kotest/testFiles.kt new file mode 100644 index 0000000..589b714 --- /dev/null +++ b/kotest/src/commonMain/kotlin/de/joshuagleitze/testfiles/kotest/testFiles.kt @@ -0,0 +1,13 @@ +package de.joshuagleitze.testfiles.kotest + +import de.joshuagleitze.testfiles.DefaultTestFiles +import de.joshuagleitze.testfiles.TestFiles + +internal val internalTestFiles: DefaultTestFiles = DefaultTestFiles() + +/** + * A [TestFiles] instance that will use the structure of the Kotest specs in this project when creating files. + * + * @sample de.joshuagleitze.testfiles.kotest.samples.ExampleSpec + */ +public val testFiles: TestFiles get() = internalTestFiles diff --git a/kotest/src/jsMain/kotlin/de/joshuagleitze/testfiles/kotest/internal/testScopeName.kt b/kotest/src/jsMain/kotlin/de/joshuagleitze/testfiles/kotest/internal/testScopeName.kt new file mode 100644 index 0000000..c130b5f --- /dev/null +++ b/kotest/src/jsMain/kotlin/de/joshuagleitze/testfiles/kotest/internal/testScopeName.kt @@ -0,0 +1,6 @@ +package de.joshuagleitze.testfiles.kotest.internal + +import kotlin.reflect.KClass + +// KClass.qualifiedName is not supported in Kotlin/JS; use simpleName instead +internal actual fun KClass<*>.testScopeName(): String = simpleName ?: "" diff --git a/kotest/src/jsTest/kotlin/de/joshuagleitze/testfiles/kotest/KotestTestFilesIntegrationSpec.kt b/kotest/src/jsTest/kotlin/de/joshuagleitze/testfiles/kotest/KotestTestFilesIntegrationSpec.kt new file mode 100644 index 0000000..49258db --- /dev/null +++ b/kotest/src/jsTest/kotlin/de/joshuagleitze/testfiles/kotest/KotestTestFilesIntegrationSpec.kt @@ -0,0 +1,25 @@ +package de.joshuagleitze.testfiles.kotest + +import de.joshuagleitze.testfiles.DefaultTestFiles +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe + +class KotestTestFilesIntegrationSpec : DescribeSpec({ +val fileRoot = DefaultTestFiles.determineTestFilesRootDirectory() + +describe("testFiles") { +val expectedGroupFolder = fileRoot / +"[${KotestTestFilesIntegrationSpec::class.simpleName}]" / +"[testFiles]" + +it("creates a test file with the appropriate name") { +val file = testFiles.createFile() +file.parent shouldBe expectedGroupFolder / "[creates a test file with the appropriate name]" +} + +it("creates a test directory with the appropriate name") { +val dir = testFiles.createDirectory() +dir.parent shouldBe expectedGroupFolder / "[creates a test directory with the appropriate name]" +} +} +}) diff --git a/kotest/src/jsTest/kotlin/de/joshuagleitze/testfiles/kotest/samples/ExampleSpec.kt b/kotest/src/jsTest/kotlin/de/joshuagleitze/testfiles/kotest/samples/ExampleSpec.kt new file mode 100644 index 0000000..9c118ea --- /dev/null +++ b/kotest/src/jsTest/kotlin/de/joshuagleitze/testfiles/kotest/samples/ExampleSpec.kt @@ -0,0 +1,22 @@ +package de.joshuagleitze.testfiles.kotest.samples + +import de.joshuagleitze.testfiles.DeletionMode.Always +import de.joshuagleitze.testfiles.DeletionMode.IfSuccessful +import de.joshuagleitze.testfiles.DeletionMode.Never +import de.joshuagleitze.testfiles.kotest.testFiles +import io.kotest.core.spec.style.DescribeSpec + +class ExampleSpec : DescribeSpec({ + describe("using test files") { + it("generates file names") { + testFiles.createFile() + testFiles.createDirectory() + } + + it("cleans up files") { + testFiles.createFile("irrelevant", delete = Always) + testFiles.createFile("default mode", delete = IfSuccessful) + testFiles.createFile("output", delete = Never) + } + } +}) diff --git a/kotest/src/jvmMain/kotlin/de/joshuagleitze/testfiles/kotest/internal/testScopeName.kt b/kotest/src/jvmMain/kotlin/de/joshuagleitze/testfiles/kotest/internal/testScopeName.kt new file mode 100644 index 0000000..7e06d3e --- /dev/null +++ b/kotest/src/jvmMain/kotlin/de/joshuagleitze/testfiles/kotest/internal/testScopeName.kt @@ -0,0 +1,5 @@ +package de.joshuagleitze.testfiles.kotest.internal + +import kotlin.reflect.KClass + +internal actual fun KClass<*>.testScopeName(): String = qualifiedName ?: simpleName ?: "" diff --git a/kotest/src/jvmTest/kotlin/de/joshuagleitze/testfiles/kotest/KotestTestFilesIntegrationSpec.kt b/kotest/src/jvmTest/kotlin/de/joshuagleitze/testfiles/kotest/KotestTestFilesIntegrationSpec.kt new file mode 100644 index 0000000..d5fb41d --- /dev/null +++ b/kotest/src/jvmTest/kotlin/de/joshuagleitze/testfiles/kotest/KotestTestFilesIntegrationSpec.kt @@ -0,0 +1,39 @@ +package de.joshuagleitze.testfiles.kotest + +import ch.tutteli.atrium.api.fluent.en_GB.isDirectory +import ch.tutteli.atrium.api.fluent.en_GB.isReadable +import ch.tutteli.atrium.api.fluent.en_GB.isRegularFile +import ch.tutteli.atrium.api.fluent.en_GB.isWritable +import ch.tutteli.atrium.api.fluent.en_GB.toBe +import ch.tutteli.atrium.api.verbs.expect +import ch.tutteli.atrium.core.polyfills.fullName +import de.joshuagleitze.testfiles.DefaultTestFiles +import io.kotest.core.spec.style.DescribeSpec + +class KotestTestFilesIntegrationSpec : DescribeSpec({ + val fileRoot = DefaultTestFiles.determineTestFilesRootDirectory() + + describe("testFiles") { + val expectedGroupFolder = fileRoot / "[${KotestTestFilesIntegrationSpec::class.fullName}]" / "[testFiles]" + + it("creates a test file with the appropriate name") { + val file = testFiles.createFile() + expect(file.toNioPath()) { + isRegularFile() + isReadable() + isWritable() + } + expect(file.parent?.toNioPath()).toBe((expectedGroupFolder / "[creates a test file with the appropriate name]").toNioPath()) + } + + it("creates a test directory with the appropriate name") { + val dir = testFiles.createDirectory() + expect(dir.toNioPath()) { + isDirectory() + isReadable() + isWritable() + } + expect(dir.parent?.toNioPath()).toBe((expectedGroupFolder / "[creates a test directory with the appropriate name]").toNioPath()) + } + } +}) diff --git a/kotest/src/jvmTest/kotlin/de/joshuagleitze/testfiles/kotest/samples/ExampleSpec.kt b/kotest/src/jvmTest/kotlin/de/joshuagleitze/testfiles/kotest/samples/ExampleSpec.kt new file mode 100644 index 0000000..9c118ea --- /dev/null +++ b/kotest/src/jvmTest/kotlin/de/joshuagleitze/testfiles/kotest/samples/ExampleSpec.kt @@ -0,0 +1,22 @@ +package de.joshuagleitze.testfiles.kotest.samples + +import de.joshuagleitze.testfiles.DeletionMode.Always +import de.joshuagleitze.testfiles.DeletionMode.IfSuccessful +import de.joshuagleitze.testfiles.DeletionMode.Never +import de.joshuagleitze.testfiles.kotest.testFiles +import io.kotest.core.spec.style.DescribeSpec + +class ExampleSpec : DescribeSpec({ + describe("using test files") { + it("generates file names") { + testFiles.createFile() + testFiles.createDirectory() + } + + it("cleans up files") { + testFiles.createFile("irrelevant", delete = Always) + testFiles.createFile("default mode", delete = IfSuccessful) + testFiles.createFile("output", delete = Never) + } + } +})