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

Move the Selfie.expectSelfie methods into selfie-lib #66

Merged
merged 14 commits into from
Dec 24, 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 @@ -15,31 +15,50 @@
*/
package com.diffplug.selfie

import com.diffplug.selfie.junit5.Router
import com.diffplug.selfie.junit5.recordCall
import java.util.Map.entry
import org.opentest4j.AssertionFailedError
import kotlin.jvm.JvmOverloads
import kotlin.jvm.JvmStatic

/** NOT FOR ENDUSERS. Implemented by Selfie to integrate with various test frameworks. */
interface SnapshotStorage {
/** Determines if the system is in write mode or read mode. */
val isWrite: Boolean
/** Indicates that the following value should be written into test sourcecode. */
fun writeInline(literalValue: LiteralValue<*>)
/** Performs a comparison between disk and actual, writing the actual to disk if necessary. */
fun readWriteDisk(actual: Snapshot, sub: String): ExpectedActual
/**
* Marks that the following sub snapshots should be kept, null means to keep all snapshots for the
* currently executing class.
*/
fun keep(subOrKeepAll: String?)
/** Creates an assertion failed exception to throw. */
fun assertFailed(message: String, expected: Any? = null, actual: Any? = null): Error
}

expect fun initStorage(): SnapshotStorage

object Selfie {
private val storage: SnapshotStorage = initStorage()

/**
* Sometimes a selfie is environment-specific, but should not be deleted when run in a different
* environment.
*/
@JvmStatic
fun preserveSelfiesOnDisk(vararg subsToKeep: String): Unit {
if (subsToKeep.isEmpty()) {
Router.keep(null)
storage.keep(null)
} else {
subsToKeep.forEach { Router.keep(it) }
subsToKeep.forEach { storage.keep(it) }
}
}

class DiskSelfie internal constructor(actual: Snapshot) : LiteralStringSelfie(actual) {
@JvmOverloads
fun toMatchDisk(sub: String = ""): Snapshot {
val comparison = Router.readWriteThroughPipeline(actual, sub)
if (!RW.isWrite) {
comparison.assertEqual()
val comparison = storage.readWriteDisk(actual, sub)
if (!storage.isWrite) {
comparison.assertEqual(storage)
}
return comparison.actual
}
Expand All @@ -58,6 +77,11 @@ object Selfie {
check(onlyFacets.isNotEmpty()) {
"Must have at least one facet to display, this was empty."
}
if (onlyFacets.contains("")) {
check(onlyFacets.indexOf("") == 0) {
"If you're going to specify the subject facet (\"\"), you have to list it first, this was $onlyFacets"
}
}
}
}
/** Extract a single facet from a snapshot in order to do an inline snapshot. */
Expand All @@ -72,16 +96,13 @@ object Selfie {
TODO("BASE64")
} else onlyValue.valueString()
} else {
// multiple values might need our SnapshotFile escaping, we'll use it just in case
val facetsToCheck =
return serializeOnlyFacets(
actual,
onlyFacets
?: buildList {
?: buildList<String> {
add("")
addAll(actual.facets.keys)
}
val snapshotToWrite =
Snapshot.ofEntries(facetsToCheck.map { entry(it, actual.subjectOrFacet(it)) })
return serializeMultiple(snapshotToWrite, !facetsToCheck.contains(""))
})
}
}
fun toBe_TODO() = toBeDidntMatch(null, actualString(), LiteralString)
Expand All @@ -103,15 +124,15 @@ object Selfie {

/** Implements the inline snapshot whenever a match fails. */
private fun <T : Any> toBeDidntMatch(expected: T?, actual: T, format: LiteralFormat<T>): T {
if (RW.isWrite) {
Router.writeInline(recordCall(), LiteralValue(expected, actual, format))
if (storage.isWrite) {
storage.writeInline(LiteralValue(expected, actual, format))
return actual
} else {
if (expected == null) {
throw AssertionFailedError(
throw storage.assertFailed(
"`.toBe_TODO()` was called in `read` mode, try again with selfie in write mode")
} else {
throw AssertionFailedError(
throw storage.assertFailed(
"Inline literal did not match the actual value", expected, actual)
}
}
Expand Down Expand Up @@ -148,61 +169,50 @@ object Selfie {
infix fun Boolean.shouldBeSelfie(expected: Boolean): Boolean = expectSelfie(this).toBe(expected)
}

internal class ExpectedActual(val expected: Snapshot?, val actual: Snapshot) {
fun assertEqual() {
class ExpectedActual(val expected: Snapshot?, val actual: Snapshot) {
internal fun assertEqual(storage: SnapshotStorage) {
if (expected == null) {
throw AssertionFailedError("No such snapshot")
throw storage.assertFailed("No such snapshot")
} else if (expected.subject == actual.subject && expected.facets == actual.facets) {
return
} else {
val allKeys =
mutableSetOf<String>()
.apply {
add("")
addAll(expected.facets.keys)
addAll(actual.facets.keys)
val mismatchedKeys =
sequence {
yield("")
yieldAll(expected.facets.keys)
for (facet in actual.facets.keys) {
if (!expected.facets.containsKey(facet)) {
yield(facet)
}
}
}
.filter { expected.subjectOrFacetMaybe(it) != actual.subjectOrFacetMaybe(it) }
.toList()
.sorted()
val mismatchInExpected = mutableMapOf<String, SnapshotValue>()
val mismatchInActual = mutableMapOf<String, SnapshotValue>()
for (key in allKeys) {
val expectedValue = expected.facets[key]
val actualValue = actual.facets[key]
if (expectedValue != actualValue) {
expectedValue?.let { mismatchInExpected[key] = it }
actualValue?.let { mismatchInActual[key] = it }
}
}
val includeRoot = mismatchInExpected.containsKey("")
throw AssertionFailedError(
throw storage.assertFailed(
"Snapshot failure",
serializeMultiple(Snapshot.ofEntries(mismatchInExpected.entries), !includeRoot),
serializeMultiple(Snapshot.ofEntries(mismatchInActual.entries), !includeRoot))
serializeOnlyFacets(expected, mismatchedKeys),
serializeOnlyFacets(actual, mismatchedKeys))
}
}
}
private fun serializeMultiple(actual: Snapshot, removeEmptySubject: Boolean): String {
if (removeEmptySubject) {
check(actual.subject.valueString().isEmpty()) {
"The subject was expected to be empty, was '${actual.subject.valueString()}'"
}
}
val file = SnapshotFile()
file.snapshots = ArrayMap.of(mutableListOf("" to actual))
/**
* Returns a serialized form of only the given facets if they are available, silently omits missing
* facets.
*/
private fun serializeOnlyFacets(snapshot: Snapshot, keys: Collection<String>): String {
val buf = StringBuilder()
file.serialize(buf::append)

check(buf.startsWith(EMPTY_SUBJECT))
check(buf.endsWith(EOF))
buf.setLength(buf.length - EOF.length)
val str = buf.substring(EMPTY_SUBJECT.length)
return if (!removeEmptySubject) str
else {
check(str[0] == '\n')
str.substring(1)
val writer = StringWriter { buf.append(it) }
for (key in keys) {
if (key.isEmpty()) {
SnapshotFile.writeValue(writer, snapshot.subjectOrFacet(key))
} else {
snapshot.subjectOrFacetMaybe(key)?.let {
SnapshotFile.writeKey(writer, "", key)
SnapshotFile.writeValue(writer, it)
}
}
}
buf.setLength(buf.length - 1)
return buf.toString()
}

private const val EMPTY_SUBJECT = "╔═ ═╗\n"
private const val EOF = "\n╔═ [end of file] ═╗\n"
Original file line number Diff line number Diff line change
Expand Up @@ -156,28 +156,6 @@ class SnapshotFile {
}
writeKey(valueWriter, "", "end of file")
}
private fun writeKey(valueWriter: StringWriter, key: String, facet: String?) {
valueWriter.write("╔═ ")
valueWriter.write(SnapshotValueReader.nameEsc.escape(key))
if (facet != null) {
valueWriter.write("[")
valueWriter.write(SnapshotValueReader.nameEsc.escape(facet))
valueWriter.write("]")
}
valueWriter.write(" ═╗\n")
}
private fun writeValue(valueWriter: StringWriter, value: SnapshotValue) {
if (value.isBinary) {
TODO("BASE64")
} else {
val escaped =
SnapshotValueReader.bodyEsc
.escape(value.valueString())
.efficientReplace("\n╔", "\n\uD801\uDF41")
valueWriter.write(escaped)
valueWriter.write("\n")
}
}

var wasSetAtTestTime: Boolean = false
fun setAtTestTime(key: String, snapshot: Snapshot) {
Expand Down Expand Up @@ -222,6 +200,28 @@ class SnapshotFile {
result.unixNewlines = unixNewlines
return result
}
internal fun writeKey(valueWriter: StringWriter, key: String, facet: String?) {
valueWriter.write("╔═ ")
valueWriter.write(SnapshotValueReader.nameEsc.escape(key))
if (facet != null) {
valueWriter.write("[")
valueWriter.write(SnapshotValueReader.nameEsc.escape(facet))
valueWriter.write("]")
}
valueWriter.write(" ═╗\n")
}
internal fun writeValue(valueWriter: StringWriter, value: SnapshotValue) {
if (value.isBinary) {
TODO("BASE64")
} else {
val escaped =
SnapshotValueReader.bodyEsc
.escape(value.valueString())
.efficientReplace("\n╔", "\n\uD801\uDF41")
valueWriter.write(escaped)
valueWriter.write("\n")
}
}
}
}

Expand Down
19 changes: 19 additions & 0 deletions selfie-lib/src/jsMain/kotlin/com/diffplug/selfie/Selfie.js.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* 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
actual fun initStorage(): SnapshotStorage {
TODO("Not yet implemented")
}
25 changes: 25 additions & 0 deletions selfie-lib/src/jvmMain/kotlin/com/diffplug/selfie/Selfie.jvm.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* 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
actual fun initStorage(): SnapshotStorage {
try {
val clazz = Class.forName("com.diffplug.selfie.junit5.SnapshotStorageJUnit5")
return clazz.getMethod("initStorage").invoke(null) as SnapshotStorage
} catch (e: ClassNotFoundException) {
throw IllegalStateException(
"Missing required artifact `com.diffplug.spotless:selfie-runner-junit5", e)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.diffplug.selfie
package com.diffplug.selfie.junit5

/**
* Determines whether Selfie is overwriting snapshots or erroring-out on mismatch.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ package com.diffplug.selfie.junit5

import com.diffplug.selfie.*
import com.diffplug.selfie.ExpectedActual
import com.diffplug.selfie.RW
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
Expand All @@ -29,17 +28,22 @@ import org.junit.platform.engine.support.descriptor.MethodSource
import org.junit.platform.launcher.TestExecutionListener
import org.junit.platform.launcher.TestIdentifier
import org.junit.platform.launcher.TestPlan
import org.opentest4j.AssertionFailedError

/** Routes between `toMatchDisk()` calls and the snapshot file / pruning machinery. */
internal object Router {
internal object SnapshotStorageJUnit5 : SnapshotStorage {
@JvmStatic fun initStorage(): SnapshotStorage = this
override val isWrite: Boolean
get() = RW.isWrite

private class ClassMethod(val clazz: ClassProgress, val method: String)
private val threadCtx = ThreadLocal<ClassMethod?>()
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 {
override fun readWriteDisk(actual: Snapshot, sub: String): ExpectedActual {
val cm = classAndMethod()
val suffix = suffix(sub)
val callStack = recordCall()
Expand All @@ -50,15 +54,19 @@ internal object Router {
ExpectedActual(cm.clazz.read(cm.method, suffix), actual)
}
}
fun keep(subOrKeepAll: String?) {
override fun keep(subOrKeepAll: String?) {
val cm = classAndMethod()
if (subOrKeepAll == null) {
cm.clazz.keep(cm.method, null)
} else {
cm.clazz.keep(cm.method, suffix(subOrKeepAll))
}
}
fun writeInline(call: CallStack, literalValue: LiteralValue<*>) {
override fun assertFailed(message: String, expected: Any?, actual: Any?): Error =
if (expected == null && actual == null) AssertionFailedError(message)
else AssertionFailedError(message, expected, actual)
override fun writeInline(literalValue: LiteralValue<*>) {
val call = recordCall()
val cm =
threadCtx.get()
?: throw AssertionError("Selfie `toBe` must be called only on the original thread.")
Expand Down Expand Up @@ -100,13 +108,13 @@ internal class ClassProgress(val parent: Progress, val className: String) {
// the methods below called by the TestExecutionListener on its runtime thread
@Synchronized fun startMethod(method: String) {
assertNotTerminated()
Router.start(this, method)
SnapshotStorageJUnit5.start(this, method)
assert(method.indexOf('/') == -1) { "Method name cannot contain '/', was $method" }
methods = methods.plus(method, MethodSnapshotGC())
}
@Synchronized fun finishedMethodWithSuccess(method: String, success: Boolean) {
assertNotTerminated()
Router.finish(this, method)
SnapshotStorageJUnit5.finish(this, method)
methods[method]!!.succeeded(success)
}
@Synchronized fun finishedClassWithSuccess(success: Boolean) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
package com.diffplug.selfie.junit5

import com.diffplug.selfie.LiteralValue
import com.diffplug.selfie.RW
import com.diffplug.selfie.Snapshot
import com.diffplug.selfie.SourceFile
import java.nio.file.Files
Expand Down
Loading