Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Refactor Selfie's settings API #47

Merged
merged 2 commits into from
Dec 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ data class Snapshot(
/** A sorted immutable map of extra values. */
val lenses: Map<String, SnapshotValue>
get() = lensData
fun withNewRoot(root: SnapshotValue) = Snapshot(root, lensData)
fun lens(key: String, value: ByteArray) = lens(key, SnapshotValue.of(value))
fun lens(key: String, value: String) = lens(key, SnapshotValue.of(value))
fun lens(key: String, value: SnapshotValue) =
Expand Down
47 changes: 27 additions & 20 deletions selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/Selfie.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,33 +26,20 @@ object Selfie {
@JvmStatic
fun preserveSelfiesOnDisk(vararg subsToKeep: String): Unit {
if (subsToKeep.isEmpty()) {
Router.readOrWriteOrKeep(null, null)
Router.keep(null)
} else {
for (sub in subsToKeep) {
Router.readOrWriteOrKeep(null, sub)
}
subsToKeep.forEach { Router.keep(it) }
}
}

open class DiskSelfie internal constructor(private val actual: Snapshot) {
@JvmOverloads
fun toMatchDisk(sub: String = ""): Snapshot {
val onDisk = Router.readOrWriteOrKeep(actual, sub)
if (RW.isWrite) return actual
else if (onDisk == null) throw AssertionFailedError("No such snapshot")
else if (actual.value != onDisk.value)
throw AssertionFailedError("Snapshot failure", onDisk.value, actual.value)
else if (actual.lenses.keys != onDisk.lenses.keys)
throw AssertionFailedError(
"Snapshot failure: mismatched lenses", onDisk.lenses.keys, actual.lenses.keys)
for (key in actual.lenses.keys) {
val actualValue = actual.lenses[key]!!
val onDiskValue = onDisk.lenses[key]!!
if (actualValue != onDiskValue) {
throw AssertionFailedError("Snapshot failure within lens $key", onDiskValue, actualValue)
}
val comparison = Router.readWriteThroughPipeline(actual, sub)
if (!RW.isWrite) {
comparison.assertEqual()
}
// if we're in read mode and the equality checks passed, stick with the disk value
return onDisk
return comparison.actual
}
}

Expand Down Expand Up @@ -103,3 +90,23 @@ object Selfie {
infix fun Long.shouldBeSelfie(expected: Long): Long = expectSelfie(this).toBe(expected)
infix fun Boolean.shouldBeSelfie(expected: Boolean): Boolean = expectSelfie(this).toBe(expected)
}

internal class ExpectedActual(val expected: Snapshot?, val actual: Snapshot) {
fun assertEqual() {
if (expected == null) {
throw AssertionFailedError("No such snapshot")
}
if (expected.value != actual.value)
throw AssertionFailedError("Snapshot failure", expected.value, actual.value)
else if (expected.lenses.keys != actual.lenses.keys)
throw AssertionFailedError(
"Snapshot failure: mismatched lenses", expected.lenses.keys, actual.lenses.keys)
for (key in expected.lenses.keys) {
val expectedValue = expected.lenses[key]!!
val actualValue = actual.lenses[key]!!
if (actualValue != expectedValue) {
throw AssertionFailedError("Snapshot failure within lens $key", expectedValue, actualValue)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Copyright (C) 2023 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.diffplug.selfie.junit5

import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths

interface SelfieSettingsAPI {
/**
* Defaults to `__snapshot__`, null means that snapshots are stored at the same folder location as
* the test that created them.
*/
val snapshotFolderName: String?
get() = "__snapshots__"

/** By default, the root folder is the first of the standard test directories. */
val rootFolder: Path
get() {
val userDir = Paths.get(System.getProperty("user.dir"))
for (standardDir in STANDARD_DIRS) {
val candidate = userDir.resolve(standardDir)
if (Files.isDirectory(candidate)) {
return candidate
}
}
throw AssertionError(
"Could not find a standard test directory, 'user.dir' is equal to $userDir, looked in $STANDARD_DIRS")
}

companion object {
private val STANDARD_DIRS =
listOf(
"src/test/java",
"src/test/kotlin",
"src/test/groovy",
"src/test/scala",
"src/test/resources")
internal fun initialize(): SelfieSettingsAPI {
try {
val clazz = Class.forName("com.diffplug.selfie.SelfieSettings")
return clazz.getDeclaredConstructor().newInstance() as SelfieSettingsAPI
} catch (e: ClassNotFoundException) {
return StandardSelfieSettings()
} catch (e: InstantiationException) {
throw AssertionError("Unable to instantiate dev.selfie.SelfieSettings, is it abstract?", e)
}
}
}
}

open class StandardSelfieSettings : SelfieSettingsAPI
Original file line number Diff line number Diff line change
Expand Up @@ -30,28 +30,28 @@ import org.junit.platform.launcher.TestPlan
internal object Router {
private class ClassMethod(val clazz: ClassProgress, val method: String)
private val threadCtx = ThreadLocal<ClassMethod?>()
fun readOrWriteOrKeep(snapshot: Snapshot?, subOrKeepAll: String?): Snapshot? {
val classMethod =
threadCtx.get()
?: throw AssertionError(
"Selfie `toMatchDisk` must be called only on the original thread.")
return if (subOrKeepAll == null) {
assert(snapshot == null)
classMethod.clazz.keep(classMethod.method, null)
null
private fun classAndMethod() =
threadCtx.get()
?: throw AssertionError(
"Selfie `toMatchDisk` must be called only on the original thread.")
private fun suffix(sub: String) = if (sub == "") "" else "/$sub"
fun readWriteThroughPipeline(actual: Snapshot, sub: String): ExpectedActual {
val cm = classAndMethod()
val suffix = suffix(sub)
val callStack = recordCall()
return if (RW.isWrite) {
cm.clazz.write(cm.method, suffix, actual, callStack)
ExpectedActual(actual, actual)
} else {
val suffix = if (subOrKeepAll == "") "" else "/$subOrKeepAll"
if (snapshot == null) {
classMethod.clazz.keep(classMethod.method, suffix)
null
} else {
if (RW.isWrite) {
classMethod.clazz.write(classMethod.method, suffix, snapshot)
snapshot
} else {
classMethod.clazz.read(classMethod.method, suffix)
}
}
ExpectedActual(cm.clazz.read(cm.method, suffix), actual)
}
}
fun keep(subOrKeepAll: String?) {
val cm = classAndMethod()
if (subOrKeepAll == null) {
cm.clazz.keep(cm.method, null)
} else {
cm.clazz.keep(cm.method, suffix(subOrKeepAll))
}
}
internal fun start(clazz: ClassProgress, method: String) {
Expand All @@ -71,18 +71,10 @@ internal object Router {
}
threadCtx.set(null)
}
fun fileLocationFor(className: String): Path {
if (layout == null) {
layout = SnapshotFileLayout.initialize(className)
}
return layout!!.snapshotPathForClass(className)
}

var layout: SnapshotFileLayout? = null
}

/** Tracks the progress of test runs within a single class, so that snapshots can be pruned. */
internal class ClassProgress(val className: String) {
internal class ClassProgress(val parent: Progress, val className: String) {
companion object {
val TERMINATED =
ArrayMap.empty<String, MethodSnapshotGC>().plus(" ~ f!n1shed ~ ", MethodSnapshotGC())
Expand Down Expand Up @@ -113,7 +105,7 @@ internal class ClassProgress(val className: String) {
MethodSnapshotGC.findStaleSnapshotsWithin(className, file!!.snapshots, methods)
if (staleSnapshotIndices.isNotEmpty() || file!!.wasSetAtTestTime) {
file!!.removeAllIndices(staleSnapshotIndices)
val snapshotPath = Router.fileLocationFor(className)
val snapshotPath = parent.layout.snapshotPathForClass(className)
if (file!!.snapshots.isEmpty()) {
deleteFileAndParentDirIfEmpty(snapshotPath)
} else {
Expand All @@ -127,7 +119,7 @@ internal class ClassProgress(val className: String) {
// we never read or wrote to the file
val isStale = MethodSnapshotGC.isUnusedSnapshotFileStale(className, methods, success)
if (isStale) {
val snapshotFile = Router.fileLocationFor(className)
val snapshotFile = parent.layout.snapshotPathForClass(className)
deleteFileAndParentDirIfEmpty(snapshotFile)
}
}
Expand All @@ -145,17 +137,14 @@ internal class ClassProgress(val className: String) {
methods[method]!!.keepSuffix(suffixOrAll)
}
}
@Synchronized fun write(method: String, suffix: String, snapshot: Snapshot) {
@Synchronized fun write(method: String, suffix: String, snapshot: Snapshot, callStack: CallStack) {
assertNotTerminated()
val key = "$method$suffix"
diskWriteTracker!!.record(key, snapshot, recordCall())
diskWriteTracker!!.record(key, snapshot, callStack, parent.layout)
methods[method]!!.keepSuffix(suffix)
read().setAtTestTime(key, snapshot)
}
@Synchronized fun read(
method: String,
suffix: String,
): Snapshot? {
@Synchronized fun read(method: String, suffix: String): Snapshot? {
assertNotTerminated()
val snapshot = read().snapshots["$method$suffix"]
if (snapshot != null) {
Expand All @@ -165,13 +154,13 @@ internal class ClassProgress(val className: String) {
}
private fun read(): SnapshotFile {
if (file == null) {
val snapshotPath = Router.fileLocationFor(className)
val snapshotPath = parent.layout.snapshotPathForClass(className)
file =
if (Files.exists(snapshotPath) && Files.isRegularFile(snapshotPath)) {
val content = Files.readAllBytes(snapshotPath)
SnapshotFile.parse(SnapshotValueReader.of(content))
} else {
SnapshotFile.createEmptyWithUnixNewlines(Router.layout!!.unixNewlines)
SnapshotFile.createEmptyWithUnixNewlines(parent.layout.unixNewlines)
}
}
return file!!
Expand All @@ -183,14 +172,17 @@ internal class ClassProgress(val className: String) {
* - pruning unused snapshot files
*/
internal class Progress {
val settings = SelfieSettingsAPI.initialize()
val layout = SnapshotFileLayout.initialize(settings)

private var progressPerClass = ArrayMap.empty<String, ClassProgress>()
private fun forClass(className: String) = synchronized(this) { progressPerClass[className]!! }

// TestExecutionListener
fun start(className: String, method: String?) {
if (method == null) {
synchronized(this) {
progressPerClass = progressPerClass.plus(className, ClassProgress(className))
progressPerClass = progressPerClass.plus(className, ClassProgress(this, className))
}
} else {
forClass(className).startMethod(method)
Expand All @@ -214,10 +206,9 @@ internal class Progress {
}
}
fun finishedAllTests() {
Router.layout?.let { layout ->
for (stale in findStaleSnapshotFiles(layout)) {
deleteFileAndParentDirIfEmpty(layout.snapshotPathForClass(stale))
}
for (stale in findStaleSnapshotFiles(layout)) {
val path = layout.snapshotPathForClass(stale)
deleteFileAndParentDirIfEmpty(path)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,27 @@ package com.diffplug.selfie.junit5

import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.io.path.name

internal class SnapshotFileLayout(
class SnapshotFileLayout(
val rootFolder: Path,
val snapshotFolderName: String?,
internal val unixNewlines: Boolean
) {
val extension: String = ".ss"
fun sourcecodeForCall(call: CallLocation): Path? {
if (call.file != null) {
return Files.walk(rootFolder).use {
it.filter { it.name == call.file }.findFirst().orElse(null)
}
}
val fileWithoutExtension = call.clazz.substringAfterLast('.').substringBefore('$')
val likelyExtensions = listOf("kt", "java", "scala", "groovy", "clj", "cljc")
val filenames = likelyExtensions.map { "$fileWithoutExtension.$it" }.toSet()
return Files.walk(rootFolder).use {
it.filter { it.name in filenames }.findFirst().orElse(null)
}
}
fun snapshotPathForClass(className: String): Path {
val lastDot = className.lastIndexOf('.')
val classFolder: Path
Expand Down Expand Up @@ -59,25 +72,18 @@ internal class SnapshotFileLayout(
}

companion object {
private const val DEFAULT_SNAPSHOT_DIR = "__snapshots__"
private val STANDARD_DIRS =
listOf(
"src/test/java",
"src/test/kotlin",
"src/test/groovy",
"src/test/scala",
"src/test/resources")
fun initialize(className: String): SnapshotFileLayout {
val selfieDotProp = SnapshotFileLayout::class.java.getResource("/selfie.properties")
val properties = java.util.Properties()
selfieDotProp?.openStream()?.use { properties.load(selfieDotProp.openStream()) }
val snapshotFolderName = snapshotFolderName(properties.getProperty("snapshot-dir"))
val snapshotRootFolder = rootFolder(properties.getProperty("output-dir"))
// it's pretty easy to preserve the line endings of existing snapshot files, but it's
// a bit harder to create a fresh snapshot file with the correct line endings.
internal fun initialize(settings: SelfieSettingsAPI): SnapshotFileLayout {
val rootFolder = settings.rootFolder
return SnapshotFileLayout(
snapshotRootFolder, snapshotFolderName, inferDefaultLineEndingIsUnix(snapshotRootFolder))
settings.rootFolder,
settings.snapshotFolderName,
inferDefaultLineEndingIsUnix(rootFolder))
}

/**
* It's pretty easy to preserve the line endings of existing snapshot files, but it's a bit
* harder to create a fresh snapshot file with the correct line endings.
*/
private fun inferDefaultLineEndingIsUnix(rootFolder: Path): Boolean {
return rootFolder
.toFile()
Expand All @@ -94,35 +100,7 @@ internal class SnapshotFileLayout(
}
}
.firstOrNull()
?.let { it.indexOf('\r') == -1 }
?: true // if we didn't find any files, assume unix
}
private fun snapshotFolderName(snapshotDir: String?): String? {
if (snapshotDir == null) {
return DEFAULT_SNAPSHOT_DIR
} else {
assert(snapshotDir.indexOf('/') == -1 && snapshotDir.indexOf('\\') == -1) {
"snapshot-dir must not contain slashes, was '$snapshotDir'"
}
assert(snapshotDir.trim() == snapshotDir) {
"snapshot-dir must not have leading or trailing whitespace, was '$snapshotDir'"
}
return snapshotDir
}
}
private fun rootFolder(rootDir: String?): Path {
val userDir = Paths.get(System.getProperty("user.dir"))
if (rootDir != null) {
return userDir.resolve(rootDir)
}
for (standardDir in STANDARD_DIRS) {
val candidate = userDir.resolve(standardDir)
if (Files.isDirectory(candidate)) {
return candidate
}
}
throw AssertionError(
"Could not find a standard test directory, 'user.dir' is equal to $userDir")
?.let { it.indexOf('\r') == -1 } ?: true // if we didn't find any files, assume unix
}
}
}
Loading