Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 31 additions & 31 deletions base/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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<KotlinCompile> {
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs += "-Xopt-in=kotlin.io.path.ExperimentalPathApi"
}
kotlinOptions {
jvmTarget = "1.8"
}
}

tasks.withType<Test> {
useJUnitPlatform()
useJUnitPlatform()

val testPwd = buildDir.resolve("test-pwd")
doFirst {
testPwd.mkdirs()
}
workingDir = testPwd
val testPwd = buildDir.resolve("test-pwd")
doFirst {
testPwd.mkdirs()
}
workingDir = testPwd
}


Original file line number Diff line number Diff line change
@@ -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<DeletionMode, MutableSet<Path>>()
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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
31 changes: 31 additions & 0 deletions base/src/commonMain/kotlin/de/joshuagleitze/testfiles/TestFiles.kt
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package de.joshuagleitze.testfiles.internal

import okio.Path

internal expect fun absolutize(path: Path): Path
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package de.joshuagleitze.testfiles.internal

internal expect val invalidFileNameCharacters: Regex
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package de.joshuagleitze.testfiles.internal

internal expect inline fun <T> synchronizedAccess(lock: Any, block: () -> T): T
Loading