Skip to content

Commit 036f609

Browse files
authored
Refactor Selfie's settings API (#47)
2 parents 7de1aa4 + 3c0a1a4 commit 036f609

File tree

7 files changed

+180
-132
lines changed

7 files changed

+180
-132
lines changed

selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/SnapshotFile.kt

+1
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ data class Snapshot(
6464
/** A sorted immutable map of extra values. */
6565
val lenses: Map<String, SnapshotValue>
6666
get() = lensData
67+
fun withNewRoot(root: SnapshotValue) = Snapshot(root, lensData)
6768
fun lens(key: String, value: ByteArray) = lens(key, SnapshotValue.of(value))
6869
fun lens(key: String, value: String) = lens(key, SnapshotValue.of(value))
6970
fun lens(key: String, value: SnapshotValue) =

selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/Selfie.kt

+27-20
Original file line numberDiff line numberDiff line change
@@ -26,33 +26,20 @@ object Selfie {
2626
@JvmStatic
2727
fun preserveSelfiesOnDisk(vararg subsToKeep: String): Unit {
2828
if (subsToKeep.isEmpty()) {
29-
Router.readOrWriteOrKeep(null, null)
29+
Router.keep(null)
3030
} else {
31-
for (sub in subsToKeep) {
32-
Router.readOrWriteOrKeep(null, sub)
33-
}
31+
subsToKeep.forEach { Router.keep(it) }
3432
}
3533
}
3634

3735
open class DiskSelfie internal constructor(private val actual: Snapshot) {
36+
@JvmOverloads
3837
fun toMatchDisk(sub: String = ""): Snapshot {
39-
val onDisk = Router.readOrWriteOrKeep(actual, sub)
40-
if (RW.isWrite) return actual
41-
else if (onDisk == null) throw AssertionFailedError("No such snapshot")
42-
else if (actual.value != onDisk.value)
43-
throw AssertionFailedError("Snapshot failure", onDisk.value, actual.value)
44-
else if (actual.lenses.keys != onDisk.lenses.keys)
45-
throw AssertionFailedError(
46-
"Snapshot failure: mismatched lenses", onDisk.lenses.keys, actual.lenses.keys)
47-
for (key in actual.lenses.keys) {
48-
val actualValue = actual.lenses[key]!!
49-
val onDiskValue = onDisk.lenses[key]!!
50-
if (actualValue != onDiskValue) {
51-
throw AssertionFailedError("Snapshot failure within lens $key", onDiskValue, actualValue)
52-
}
38+
val comparison = Router.readWriteThroughPipeline(actual, sub)
39+
if (!RW.isWrite) {
40+
comparison.assertEqual()
5341
}
54-
// if we're in read mode and the equality checks passed, stick with the disk value
55-
return onDisk
42+
return comparison.actual
5643
}
5744
}
5845

@@ -103,3 +90,23 @@ object Selfie {
10390
infix fun Long.shouldBeSelfie(expected: Long): Long = expectSelfie(this).toBe(expected)
10491
infix fun Boolean.shouldBeSelfie(expected: Boolean): Boolean = expectSelfie(this).toBe(expected)
10592
}
93+
94+
internal class ExpectedActual(val expected: Snapshot?, val actual: Snapshot) {
95+
fun assertEqual() {
96+
if (expected == null) {
97+
throw AssertionFailedError("No such snapshot")
98+
}
99+
if (expected.value != actual.value)
100+
throw AssertionFailedError("Snapshot failure", expected.value, actual.value)
101+
else if (expected.lenses.keys != actual.lenses.keys)
102+
throw AssertionFailedError(
103+
"Snapshot failure: mismatched lenses", expected.lenses.keys, actual.lenses.keys)
104+
for (key in expected.lenses.keys) {
105+
val expectedValue = expected.lenses[key]!!
106+
val actualValue = actual.lenses[key]!!
107+
if (actualValue != expectedValue) {
108+
throw AssertionFailedError("Snapshot failure within lens $key", expectedValue, actualValue)
109+
}
110+
}
111+
}
112+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright (C) 2023 DiffPlug
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.diffplug.selfie.junit5
17+
18+
import java.nio.file.Files
19+
import java.nio.file.Path
20+
import java.nio.file.Paths
21+
22+
interface SelfieSettingsAPI {
23+
/**
24+
* Defaults to `__snapshot__`, null means that snapshots are stored at the same folder location as
25+
* the test that created them.
26+
*/
27+
val snapshotFolderName: String?
28+
get() = "__snapshots__"
29+
30+
/** By default, the root folder is the first of the standard test directories. */
31+
val rootFolder: Path
32+
get() {
33+
val userDir = Paths.get(System.getProperty("user.dir"))
34+
for (standardDir in STANDARD_DIRS) {
35+
val candidate = userDir.resolve(standardDir)
36+
if (Files.isDirectory(candidate)) {
37+
return candidate
38+
}
39+
}
40+
throw AssertionError(
41+
"Could not find a standard test directory, 'user.dir' is equal to $userDir, looked in $STANDARD_DIRS")
42+
}
43+
44+
companion object {
45+
private val STANDARD_DIRS =
46+
listOf(
47+
"src/test/java",
48+
"src/test/kotlin",
49+
"src/test/groovy",
50+
"src/test/scala",
51+
"src/test/resources")
52+
internal fun initialize(): SelfieSettingsAPI {
53+
try {
54+
val clazz = Class.forName("com.diffplug.selfie.SelfieSettings")
55+
return clazz.getDeclaredConstructor().newInstance() as SelfieSettingsAPI
56+
} catch (e: ClassNotFoundException) {
57+
return StandardSelfieSettings()
58+
} catch (e: InstantiationException) {
59+
throw AssertionError("Unable to instantiate dev.selfie.SelfieSettings, is it abstract?", e)
60+
}
61+
}
62+
}
63+
}
64+
65+
open class StandardSelfieSettings : SelfieSettingsAPI

selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SelfieTestExecutionListener.kt

+36-45
Original file line numberDiff line numberDiff line change
@@ -30,28 +30,28 @@ import org.junit.platform.launcher.TestPlan
3030
internal object Router {
3131
private class ClassMethod(val clazz: ClassProgress, val method: String)
3232
private val threadCtx = ThreadLocal<ClassMethod?>()
33-
fun readOrWriteOrKeep(snapshot: Snapshot?, subOrKeepAll: String?): Snapshot? {
34-
val classMethod =
35-
threadCtx.get()
36-
?: throw AssertionError(
37-
"Selfie `toMatchDisk` must be called only on the original thread.")
38-
return if (subOrKeepAll == null) {
39-
assert(snapshot == null)
40-
classMethod.clazz.keep(classMethod.method, null)
41-
null
33+
private fun classAndMethod() =
34+
threadCtx.get()
35+
?: throw AssertionError(
36+
"Selfie `toMatchDisk` must be called only on the original thread.")
37+
private fun suffix(sub: String) = if (sub == "") "" else "/$sub"
38+
fun readWriteThroughPipeline(actual: Snapshot, sub: String): ExpectedActual {
39+
val cm = classAndMethod()
40+
val suffix = suffix(sub)
41+
val callStack = recordCall()
42+
return if (RW.isWrite) {
43+
cm.clazz.write(cm.method, suffix, actual, callStack)
44+
ExpectedActual(actual, actual)
4245
} else {
43-
val suffix = if (subOrKeepAll == "") "" else "/$subOrKeepAll"
44-
if (snapshot == null) {
45-
classMethod.clazz.keep(classMethod.method, suffix)
46-
null
47-
} else {
48-
if (RW.isWrite) {
49-
classMethod.clazz.write(classMethod.method, suffix, snapshot)
50-
snapshot
51-
} else {
52-
classMethod.clazz.read(classMethod.method, suffix)
53-
}
54-
}
46+
ExpectedActual(cm.clazz.read(cm.method, suffix), actual)
47+
}
48+
}
49+
fun keep(subOrKeepAll: String?) {
50+
val cm = classAndMethod()
51+
if (subOrKeepAll == null) {
52+
cm.clazz.keep(cm.method, null)
53+
} else {
54+
cm.clazz.keep(cm.method, suffix(subOrKeepAll))
5555
}
5656
}
5757
internal fun start(clazz: ClassProgress, method: String) {
@@ -71,18 +71,10 @@ internal object Router {
7171
}
7272
threadCtx.set(null)
7373
}
74-
fun fileLocationFor(className: String): Path {
75-
if (layout == null) {
76-
layout = SnapshotFileLayout.initialize(className)
77-
}
78-
return layout!!.snapshotPathForClass(className)
79-
}
80-
81-
var layout: SnapshotFileLayout? = null
8274
}
8375

8476
/** Tracks the progress of test runs within a single class, so that snapshots can be pruned. */
85-
internal class ClassProgress(val className: String) {
77+
internal class ClassProgress(val parent: Progress, val className: String) {
8678
companion object {
8779
val TERMINATED =
8880
ArrayMap.empty<String, MethodSnapshotGC>().plus(" ~ f!n1shed ~ ", MethodSnapshotGC())
@@ -113,7 +105,7 @@ internal class ClassProgress(val className: String) {
113105
MethodSnapshotGC.findStaleSnapshotsWithin(className, file!!.snapshots, methods)
114106
if (staleSnapshotIndices.isNotEmpty() || file!!.wasSetAtTestTime) {
115107
file!!.removeAllIndices(staleSnapshotIndices)
116-
val snapshotPath = Router.fileLocationFor(className)
108+
val snapshotPath = parent.layout.snapshotPathForClass(className)
117109
if (file!!.snapshots.isEmpty()) {
118110
deleteFileAndParentDirIfEmpty(snapshotPath)
119111
} else {
@@ -127,7 +119,7 @@ internal class ClassProgress(val className: String) {
127119
// we never read or wrote to the file
128120
val isStale = MethodSnapshotGC.isUnusedSnapshotFileStale(className, methods, success)
129121
if (isStale) {
130-
val snapshotFile = Router.fileLocationFor(className)
122+
val snapshotFile = parent.layout.snapshotPathForClass(className)
131123
deleteFileAndParentDirIfEmpty(snapshotFile)
132124
}
133125
}
@@ -145,17 +137,14 @@ internal class ClassProgress(val className: String) {
145137
methods[method]!!.keepSuffix(suffixOrAll)
146138
}
147139
}
148-
@Synchronized fun write(method: String, suffix: String, snapshot: Snapshot) {
140+
@Synchronized fun write(method: String, suffix: String, snapshot: Snapshot, callStack: CallStack) {
149141
assertNotTerminated()
150142
val key = "$method$suffix"
151-
diskWriteTracker!!.record(key, snapshot, recordCall())
143+
diskWriteTracker!!.record(key, snapshot, callStack, parent.layout)
152144
methods[method]!!.keepSuffix(suffix)
153145
read().setAtTestTime(key, snapshot)
154146
}
155-
@Synchronized fun read(
156-
method: String,
157-
suffix: String,
158-
): Snapshot? {
147+
@Synchronized fun read(method: String, suffix: String): Snapshot? {
159148
assertNotTerminated()
160149
val snapshot = read().snapshots["$method$suffix"]
161150
if (snapshot != null) {
@@ -165,13 +154,13 @@ internal class ClassProgress(val className: String) {
165154
}
166155
private fun read(): SnapshotFile {
167156
if (file == null) {
168-
val snapshotPath = Router.fileLocationFor(className)
157+
val snapshotPath = parent.layout.snapshotPathForClass(className)
169158
file =
170159
if (Files.exists(snapshotPath) && Files.isRegularFile(snapshotPath)) {
171160
val content = Files.readAllBytes(snapshotPath)
172161
SnapshotFile.parse(SnapshotValueReader.of(content))
173162
} else {
174-
SnapshotFile.createEmptyWithUnixNewlines(Router.layout!!.unixNewlines)
163+
SnapshotFile.createEmptyWithUnixNewlines(parent.layout.unixNewlines)
175164
}
176165
}
177166
return file!!
@@ -183,14 +172,17 @@ internal class ClassProgress(val className: String) {
183172
* - pruning unused snapshot files
184173
*/
185174
internal class Progress {
175+
val settings = SelfieSettingsAPI.initialize()
176+
val layout = SnapshotFileLayout.initialize(settings)
177+
186178
private var progressPerClass = ArrayMap.empty<String, ClassProgress>()
187179
private fun forClass(className: String) = synchronized(this) { progressPerClass[className]!! }
188180

189181
// TestExecutionListener
190182
fun start(className: String, method: String?) {
191183
if (method == null) {
192184
synchronized(this) {
193-
progressPerClass = progressPerClass.plus(className, ClassProgress(className))
185+
progressPerClass = progressPerClass.plus(className, ClassProgress(this, className))
194186
}
195187
} else {
196188
forClass(className).startMethod(method)
@@ -214,10 +206,9 @@ internal class Progress {
214206
}
215207
}
216208
fun finishedAllTests() {
217-
Router.layout?.let { layout ->
218-
for (stale in findStaleSnapshotFiles(layout)) {
219-
deleteFileAndParentDirIfEmpty(layout.snapshotPathForClass(stale))
220-
}
209+
for (stale in findStaleSnapshotFiles(layout)) {
210+
val path = layout.snapshotPathForClass(stale)
211+
deleteFileAndParentDirIfEmpty(path)
221212
}
222213
}
223214
}

selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SelfieConfig.kt selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SnapshotFileLayout.kt

+26-48
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,27 @@ package com.diffplug.selfie.junit5
1717

1818
import java.nio.file.Files
1919
import java.nio.file.Path
20-
import java.nio.file.Paths
20+
import kotlin.io.path.name
2121

22-
internal class SnapshotFileLayout(
22+
class SnapshotFileLayout(
2323
val rootFolder: Path,
2424
val snapshotFolderName: String?,
2525
internal val unixNewlines: Boolean
2626
) {
2727
val extension: String = ".ss"
28+
fun sourcecodeForCall(call: CallLocation): Path? {
29+
if (call.file != null) {
30+
return Files.walk(rootFolder).use {
31+
it.filter { it.name == call.file }.findFirst().orElse(null)
32+
}
33+
}
34+
val fileWithoutExtension = call.clazz.substringAfterLast('.').substringBefore('$')
35+
val likelyExtensions = listOf("kt", "java", "scala", "groovy", "clj", "cljc")
36+
val filenames = likelyExtensions.map { "$fileWithoutExtension.$it" }.toSet()
37+
return Files.walk(rootFolder).use {
38+
it.filter { it.name in filenames }.findFirst().orElse(null)
39+
}
40+
}
2841
fun snapshotPathForClass(className: String): Path {
2942
val lastDot = className.lastIndexOf('.')
3043
val classFolder: Path
@@ -59,25 +72,18 @@ internal class SnapshotFileLayout(
5972
}
6073

6174
companion object {
62-
private const val DEFAULT_SNAPSHOT_DIR = "__snapshots__"
63-
private val STANDARD_DIRS =
64-
listOf(
65-
"src/test/java",
66-
"src/test/kotlin",
67-
"src/test/groovy",
68-
"src/test/scala",
69-
"src/test/resources")
70-
fun initialize(className: String): SnapshotFileLayout {
71-
val selfieDotProp = SnapshotFileLayout::class.java.getResource("/selfie.properties")
72-
val properties = java.util.Properties()
73-
selfieDotProp?.openStream()?.use { properties.load(selfieDotProp.openStream()) }
74-
val snapshotFolderName = snapshotFolderName(properties.getProperty("snapshot-dir"))
75-
val snapshotRootFolder = rootFolder(properties.getProperty("output-dir"))
76-
// it's pretty easy to preserve the line endings of existing snapshot files, but it's
77-
// a bit harder to create a fresh snapshot file with the correct line endings.
75+
internal fun initialize(settings: SelfieSettingsAPI): SnapshotFileLayout {
76+
val rootFolder = settings.rootFolder
7877
return SnapshotFileLayout(
79-
snapshotRootFolder, snapshotFolderName, inferDefaultLineEndingIsUnix(snapshotRootFolder))
78+
settings.rootFolder,
79+
settings.snapshotFolderName,
80+
inferDefaultLineEndingIsUnix(rootFolder))
8081
}
82+
83+
/**
84+
* It's pretty easy to preserve the line endings of existing snapshot files, but it's a bit
85+
* harder to create a fresh snapshot file with the correct line endings.
86+
*/
8187
private fun inferDefaultLineEndingIsUnix(rootFolder: Path): Boolean {
8288
return rootFolder
8389
.toFile()
@@ -94,35 +100,7 @@ internal class SnapshotFileLayout(
94100
}
95101
}
96102
.firstOrNull()
97-
?.let { it.indexOf('\r') == -1 }
98-
?: true // if we didn't find any files, assume unix
99-
}
100-
private fun snapshotFolderName(snapshotDir: String?): String? {
101-
if (snapshotDir == null) {
102-
return DEFAULT_SNAPSHOT_DIR
103-
} else {
104-
assert(snapshotDir.indexOf('/') == -1 && snapshotDir.indexOf('\\') == -1) {
105-
"snapshot-dir must not contain slashes, was '$snapshotDir'"
106-
}
107-
assert(snapshotDir.trim() == snapshotDir) {
108-
"snapshot-dir must not have leading or trailing whitespace, was '$snapshotDir'"
109-
}
110-
return snapshotDir
111-
}
112-
}
113-
private fun rootFolder(rootDir: String?): Path {
114-
val userDir = Paths.get(System.getProperty("user.dir"))
115-
if (rootDir != null) {
116-
return userDir.resolve(rootDir)
117-
}
118-
for (standardDir in STANDARD_DIRS) {
119-
val candidate = userDir.resolve(standardDir)
120-
if (Files.isDirectory(candidate)) {
121-
return candidate
122-
}
123-
}
124-
throw AssertionError(
125-
"Could not find a standard test directory, 'user.dir' is equal to $userDir")
103+
?.let { it.indexOf('\r') == -1 } ?: true // if we didn't find any files, assume unix
126104
}
127105
}
128106
}

0 commit comments

Comments
 (0)