Skip to content

Commit 05a3112

Browse files
committed
Merge branch 'main' into feat/carriage-return
2 parents 530423e + 91c1048 commit 05a3112

File tree

11 files changed

+416
-33
lines changed

11 files changed

+416
-33
lines changed

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

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,6 @@ class SnapshotFile {
133133

134134
var wasSetAtTestTime: Boolean = false
135135
fun setAtTestTime(key: String, snapshot: Snapshot) {
136-
// TODO: track whenever a snapshot is set, so that we can:
137-
// - warn about duplicate snapshots when they are equal
138-
// - give good errors when they are not
139136
val newSnapshots = snapshots.plusOrReplace(key, snapshot)
140137
if (newSnapshots !== snapshots) {
141138
snapshots = newSnapshots

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

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,34 +21,39 @@ package com.diffplug.selfie
2121
* - if environment variable or system property named `ci` or `CI` with value `true` or `TRUE`
2222
* - then Selfie is read-only and errors out on a snapshot mismatch
2323
* - if environment variable or system property named `selfie` or `SELFIE`
24-
* - its value should be either `read` or `write` (case-insensitive)
25-
* - that will override the presence of `CI`
24+
* - its value should be either `read`, `write`, or `writeonce` (case-insensitive)
25+
* - `write` allows a single snapshot to be set multiple times within a test, so long as it is
26+
* the same value. `writeonce` errors as soon as a snapshot is set twice even to the same
27+
* value.
28+
* - selfie, if set, will override the presence of `CI`
2629
*/
27-
internal object RW {
28-
private fun lowercaseFromEnvOrSys(key: String): String? {
29-
val env = System.getenv(key)?.lowercase()
30-
if (!env.isNullOrEmpty()) {
31-
return env
32-
}
33-
val system = System.getProperty(key)?.lowercase()
34-
if (!system.isNullOrEmpty()) {
35-
return system
30+
internal enum class RW {
31+
read,
32+
write,
33+
writeonce;
34+
35+
companion object {
36+
private fun lowercaseFromEnvOrSys(key: String): String? {
37+
val env = System.getenv(key)?.lowercase()
38+
if (!env.isNullOrEmpty()) {
39+
return env
40+
}
41+
val system = System.getProperty(key)?.lowercase()
42+
if (!system.isNullOrEmpty()) {
43+
return system
44+
}
45+
return null
3646
}
37-
return null
38-
}
39-
private fun calcIsWrite(): Boolean {
40-
val override = lowercaseFromEnvOrSys("selfie") ?: lowercaseFromEnvOrSys("SELFIE")
41-
if (override != null) {
42-
return when (override) {
43-
"read" -> false
44-
"write" -> true
45-
else ->
46-
throw IllegalArgumentException(
47-
"Expected 'selfie' to be 'read' or 'write', but was '$override'")
47+
private fun calcRW(): RW {
48+
val override = lowercaseFromEnvOrSys("selfie") ?: lowercaseFromEnvOrSys("SELFIE")
49+
if (override != null) {
50+
return RW.valueOf(override)
4851
}
52+
val ci = lowercaseFromEnvOrSys("ci") ?: lowercaseFromEnvOrSys("CI")
53+
return if (ci == "true") read else write
4954
}
50-
val ci = lowercaseFromEnvOrSys("ci") ?: lowercaseFromEnvOrSys("CI")
51-
return ci != "true"
55+
val mode = calcRW()
56+
val isWrite = mode != read
57+
val isWriteOnce = mode == writeonce
5258
}
53-
val isWrite = calcIsWrite()
5459
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ internal class MethodSnapshotGC {
103103
continue
104104
} else if (key.elementAt(gc.key.length) == '/') {
105105
// startWith + not same length = can safely query the `/`
106-
val suffix = key.substring(gc.key.length + 1)
106+
val suffix = key.substring(gc.key.length)
107107
if (!gc.value.keeps(suffix)) {
108108
staleIndices.add(keyIdx)
109109
}
@@ -162,6 +162,7 @@ internal class MethodSnapshotGC {
162162
private val EMPTY_SET = ArraySet<String>(arrayOf())
163163
}
164164
}
165+
165166
/** An immutable, sorted, array-backed set. */
166167
internal class ArraySet<K : Comparable<K>>(private val data: Array<Any>) : ListBackedSet<K>() {
167168
override val size: Int

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ internal class ClassProgress(val className: String) {
9393

9494
private var file: SnapshotFile? = null
9595
private var methods = ArrayMap.empty<String, MethodSnapshotGC>()
96+
private var diskWriteTracker: DiskWriteTracker? = DiskWriteTracker()
9697
// the methods below called by the TestExecutionListener on its runtime thread
9798
@Synchronized fun startMethod(method: String) {
9899
assertNotTerminated()
@@ -132,6 +133,7 @@ internal class ClassProgress(val className: String) {
132133
}
133134
// now that we are done, allow our contents to be GC'ed
134135
methods = TERMINATED
136+
diskWriteTracker = null
135137
file = null
136138
}
137139
// the methods below are called from the test thread for I/O on snapshots
@@ -145,8 +147,10 @@ internal class ClassProgress(val className: String) {
145147
}
146148
@Synchronized fun write(method: String, suffix: String, snapshot: Snapshot) {
147149
assertNotTerminated()
150+
val key = "$method$suffix"
151+
diskWriteTracker!!.record(key, snapshot, recordCall())
148152
methods[method]!!.keepSuffix(suffix)
149-
read().setAtTestTime("$method$suffix", snapshot)
153+
read().setAtTestTime(key, snapshot)
150154
}
151155
@Synchronized fun read(
152156
method: String,
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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 com.diffplug.selfie.RW
19+
import com.diffplug.selfie.Snapshot
20+
import java.util.stream.Collectors
21+
22+
/** Represents the line at which user code called into Selfie. */
23+
data class CallLocation(val subpath: String, val line: Int) : Comparable<CallLocation> {
24+
override fun compareTo(other: CallLocation): Int {
25+
val subpathCompare = subpath.compareTo(other.subpath)
26+
return if (subpathCompare != 0) subpathCompare else line.compareTo(other.line)
27+
}
28+
override fun toString(): String = "$subpath:$line"
29+
}
30+
/** Represents the callstack above a given CallLocation. */
31+
class CallStack(val location: CallLocation, val restOfStack: List<CallLocation>) {
32+
override fun toString(): String = "$location"
33+
}
34+
/** Generates a CallLocation and the CallStack behind it. */
35+
fun recordCall(): CallStack {
36+
val calls =
37+
StackWalker.getInstance().walk { frames ->
38+
frames
39+
.skip(1)
40+
.map { CallLocation(it.className.replace('.', '/') + ".kt", it.lineNumber) }
41+
.collect(Collectors.toList())
42+
}
43+
return CallStack(calls.removeAt(0), calls)
44+
}
45+
/** The first write at a given spot. */
46+
internal class FirstWrite<T>(val snapshot: T, val callStack: CallStack)
47+
48+
/** For tracking the writes of disk snapshots literals. */
49+
internal open class WriteTracker<K : Comparable<K>, V> {
50+
val writes = mutableMapOf<K, FirstWrite<V>>()
51+
protected fun recordInternal(key: K, snapshot: V, call: CallStack) {
52+
val existing = writes.putIfAbsent(key, FirstWrite(snapshot, call))
53+
if (existing != null) {
54+
if (existing.snapshot != snapshot) {
55+
throw org.opentest4j.AssertionFailedError(
56+
"Snapshot was set to multiple values:\nfirst time:${existing.callStack}\n\nthis time:${call}",
57+
existing.snapshot,
58+
snapshot)
59+
} else if (RW.isWriteOnce) {
60+
throw org.opentest4j.AssertionFailedError(
61+
"Snapshot was set to the same value multiple times.", existing.callStack, call)
62+
}
63+
}
64+
}
65+
}
66+
67+
internal class DiskWriteTracker : WriteTracker<String, Snapshot>() {
68+
fun record(key: String, snapshot: Snapshot, call: CallStack) {
69+
recordInternal(key, snapshot, call)
70+
}
71+
}
72+
73+
class LiteralValue {
74+
// TODO: String, Int, Long, Boolean, etc
75+
}
76+
77+
internal class InlineWriteTracker : WriteTracker<CallLocation, LiteralValue>() {
78+
fun record(call: CallStack, snapshot: LiteralValue) {
79+
recordInternal(call.location, snapshot, call)
80+
}
81+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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 io.kotest.matchers.shouldBe
19+
import io.kotest.matchers.string.shouldStartWith
20+
import kotlin.test.Test
21+
import org.junit.jupiter.api.MethodOrderer
22+
import org.junit.jupiter.api.Order
23+
import org.junit.jupiter.api.TestMethodOrder
24+
import org.junitpioneer.jupiter.DisableIfTestFails
25+
26+
/** Simplest test for verifying read/write of a snapshot. */
27+
@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
28+
@DisableIfTestFails
29+
class DuplicateWriteTest : Harness("undertest-junit5") {
30+
@Test @Order(1)
31+
fun noSelfie() {
32+
ut_snapshot().deleteIfExists()
33+
ut_snapshot().assertDoesNotExist()
34+
}
35+
36+
@Test @Order(2)
37+
fun cannot_write_multiple_things_to_one_snapshot() {
38+
ut_mirror().linesFrom("fun shouldFail()").toFirst("}").uncomment()
39+
ut_mirror().linesFrom("fun shouldPass()").toFirst("}").commentOut()
40+
gradlew("underTest", "-Pselfie=write")!!.message shouldStartWith
41+
"Snapshot was set to multiple values"
42+
}
43+
44+
@Test @Order(3)
45+
fun can_write_one_thing_multiple_times_to_one_snapshot() {
46+
ut_mirror().linesFrom("fun shouldFail()").toFirst("}").commentOut()
47+
ut_mirror().linesFrom("fun shouldPass()").toFirst("}").uncomment()
48+
gradlew("underTest", "-Pselfie=write") shouldBe null
49+
}
50+
51+
@Test @Order(4)
52+
fun can_read_one_thing_multiple_times_from_one_snapshot() {
53+
ut_mirror().linesFrom("fun shouldFail()").toFirst("}").commentOut()
54+
ut_mirror().linesFrom("fun shouldPass()").toFirst("}").uncomment()
55+
gradlew("underTest", "-Pselfie=read") shouldBe null
56+
}
57+
58+
@Test @Order(5)
59+
fun writeonce_mode() {
60+
ut_mirror().linesFrom("fun shouldFail()").toFirst("}").commentOut()
61+
ut_mirror().linesFrom("fun shouldPass()").toFirst("}").uncomment()
62+
gradlew("underTest", "-Pselfie=writeonce")!!.message shouldStartWith
63+
"Snapshot was set to the same value multiple times"
64+
}
65+
66+
@Test @Order(6)
67+
fun deleteSelfie() {
68+
ut_snapshot().deleteIfExists()
69+
}
70+
}

selfie-runner-junit5/src/test/kotlin/com/diffplug/selfie/junit5/Harness.kt

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,20 @@
1616
package com.diffplug.selfie.junit5
1717

1818
import io.kotest.matchers.shouldBe
19+
import java.io.StringReader
20+
import java.util.regex.Matcher
21+
import java.util.regex.Pattern
22+
import javax.xml.parsers.DocumentBuilderFactory
23+
import javax.xml.xpath.XPathConstants
24+
import javax.xml.xpath.XPathFactory
1925
import okio.FileSystem
2026
import okio.Path
2127
import okio.Path.Companion.toPath
28+
import org.gradle.internal.impldep.junit.framework.AssertionFailedError
2229
import org.gradle.tooling.BuildException
2330
import org.gradle.tooling.GradleConnector
31+
import org.w3c.dom.NodeList
32+
import org.xml.sax.InputSource
2433

2534
open class Harness(subproject: String) {
2635
val subprojectFolder: Path
@@ -165,7 +174,7 @@ open class Harness(subproject: String) {
165174
}
166175
}
167176
}
168-
fun gradlew(task: String, vararg args: String): BuildException? {
177+
fun gradlew(task: String, vararg args: String): AssertionFailedError? {
169178
val runner =
170179
GradleConnector.newConnector()
171180
.forProjectDirectory(subprojectFolder.parent!!.toFile())
@@ -185,9 +194,56 @@ open class Harness(subproject: String) {
185194
buildLauncher.run()
186195
return null
187196
} catch (e: BuildException) {
188-
return e
197+
return parseBuildException(task)
189198
}
190199
}
200+
201+
/**
202+
* Parse build exception from gradle by looking into <code>build</code> directory to the matching
203+
* test.
204+
*
205+
* Parses the exception message as well as stacktrace.
206+
*/
207+
private fun parseBuildException(task: String): AssertionFailedError {
208+
val xmlFile =
209+
subprojectFolder
210+
.resolve("build")
211+
.resolve("test-results")
212+
.resolve(task)
213+
.resolve("TEST-" + task.lowercase() + ".junit5.UT_" + javaClass.simpleName + ".xml")
214+
val xml = FileSystem.SYSTEM.read(xmlFile) { readUtf8() }
215+
val dbFactory = DocumentBuilderFactory.newInstance()
216+
val dBuilder = dbFactory.newDocumentBuilder()
217+
val xmlInput = InputSource(StringReader(xml))
218+
val doc = dBuilder.parse(xmlInput)
219+
val xpFactory = XPathFactory.newInstance()
220+
val xPath = xpFactory.newXPath()
221+
222+
// <item type="T1" count="1">Value1</item>
223+
val xpath = "/testsuite/testcase/failure"
224+
val failures = xPath.evaluate(xpath, doc, XPathConstants.NODESET) as NodeList
225+
val failure = failures.item(0)
226+
val type = failure.attributes.getNamedItem("type").nodeValue
227+
val message = failure.attributes.getNamedItem("message").nodeValue.replace("&#10;", "\n")
228+
val lines = failure.textContent.replace(message, "").trim().split("\n")
229+
val stacktrace: MutableList<StackTraceElement> = ArrayList()
230+
val tracePattern =
231+
Pattern.compile("\\s*at\\s+([\\w]+)//([\\w\\.]+)\\.([\\w]+)(\\(.*kt)?:(\\d+)\\)")
232+
lines.forEach {
233+
val traceMatcher: Matcher = tracePattern.matcher(it)
234+
while (traceMatcher.find()) {
235+
val module: String = traceMatcher.group(1)
236+
val className: String = module + "//" + traceMatcher.group(2)
237+
val methodName: String = traceMatcher.group(3)
238+
val sourceFile: String = traceMatcher.group(4)
239+
val lineNum: Int = traceMatcher.group(5).toInt()
240+
stacktrace.add(StackTraceElement(className, methodName, sourceFile, lineNum))
241+
}
242+
}
243+
val error = AssertionFailedError(message.replace("$type: ", "").trim())
244+
error.stackTrace = stacktrace.toTypedArray()
245+
return error
246+
}
191247
fun gradleWriteSS() {
192248
gradlew("underTest", "-Pselfie=write")?.let {
193249
throw AssertionError("Expected write snapshots to succeed, but it failed", it)
@@ -198,7 +254,7 @@ open class Harness(subproject: String) {
198254
throw AssertionError("Expected read snapshots to succeed, but it failed", it)
199255
}
200256
}
201-
fun gradleReadSSFail(): BuildException {
257+
fun gradleReadSSFail(): AssertionFailedError {
202258
val failure = gradlew("underTest", "-Pselfie=read")
203259
if (failure == null) {
204260
throw AssertionError("Expected read snapshots to fail, but it succeeded.")

0 commit comments

Comments
 (0)