From 91b890d4fe430445d4e98ab0310bad7751f4358a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 20 Feb 2025 15:11:03 -0800 Subject: [PATCH 01/10] When determining where a call comes from, ignore `com.diffplug.selfie.*` and `selfie.*` --- .../kotlin/com/diffplug/selfie/guts/WriteTracker.jvm.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/jvm/selfie-lib/src/jvmMain/kotlin/com/diffplug/selfie/guts/WriteTracker.jvm.kt b/jvm/selfie-lib/src/jvmMain/kotlin/com/diffplug/selfie/guts/WriteTracker.jvm.kt index fcb7f19d0..a01a6846c 100644 --- a/jvm/selfie-lib/src/jvmMain/kotlin/com/diffplug/selfie/guts/WriteTracker.jvm.kt +++ b/jvm/selfie-lib/src/jvmMain/kotlin/com/diffplug/selfie/guts/WriteTracker.jvm.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 DiffPlug + * Copyright (C) 2024-2025 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -55,7 +55,10 @@ actual data class CallLocation( /** Generates a CallLocation and the CallStack behind it. */ internal actual fun recordCall(callerFileOnly: Boolean): CallStack = StackWalker.getInstance().walk { frames -> - val framesWithDrop = frames.dropWhile { it.className.startsWith("com.diffplug.selfie") } + val framesWithDrop = + frames.dropWhile { + it.className.startsWith("com.diffplug.selfie.") || it.className.startsWith("selfie.") + } if (callerFileOnly) { val caller = framesWithDrop From 07598e7eda470310a0ad2a04ea4afa82c78935a3 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 20 Feb 2025 15:49:14 -0800 Subject: [PATCH 02/10] First cut. --- .../kotlin/com/diffplug/selfie/Selfie.kt | 3 +- .../kotlin/com/diffplug/selfie/VcrSelfie.kt | 102 ++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt diff --git a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Selfie.kt b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Selfie.kt index 49b31e9c2..bdc2b95e2 100644 --- a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Selfie.kt +++ b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Selfie.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023-2024 DiffPlug + * Copyright (C) 2023-2025 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -88,4 +88,5 @@ object Selfie { @JvmStatic fun cacheSelfieBinary(roundtrip: Roundtrip, toCache: Cacheable) = CacheSelfieBinary(deferredDiskStorage, roundtrip, toCache) + @JvmStatic fun vcrTestLocator(sub: String = "") = VcrSelfie.TestLocator(sub, deferredDiskStorage) } diff --git a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt new file mode 100644 index 000000000..179f037c6 --- /dev/null +++ b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2025 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 + +import com.diffplug.selfie.guts.CallStack +import com.diffplug.selfie.guts.DiskStorage +import com.diffplug.selfie.guts.recordCall + +private const val OPEN = "«" +private const val CLOSE = "»" + +class VcrSelfie( + private val sub: String, + private val call: CallStack, + private val disk: DiskStorage, +) : AutoCloseable { + class TestLocator internal constructor(private val sub: String, private val disk: DiskStorage) { + private val call = recordCall(false) + fun createVcr() = VcrSelfie(sub, call, disk) + } + + private class State(val readMode: Boolean) { + var count = 0 + val sequence = mutableListOf>() + } + private val state: State + + init { + val canWrite = Selfie.system.mode.canWrite(isTodo = false, call, Selfie.system) + state = State(readMode = !canWrite) + if (state.readMode) { + val snapshot = + disk.readDisk(sub, call) + ?: throw Selfie.system.fs.assertFailed(Selfie.system.mode.msgSnapshotNotFound()) + var idx = 1 + for ((key, value) in snapshot.facets) { + check(key.startsWith(OPEN)) + val nextClose = key.indexOf(CLOSE) + check(nextClose != -1) + val num = key.substring(OPEN.length, nextClose).toInt() + check(num == idx) + ++idx + val keyAfterNum = key.substring(nextClose + 1) + state.sequence.add(keyAfterNum to value) + } + } + } + override fun close() { + if (state.readMode) { + check(state.count == state.sequence.size) + } else { + var snapshot = Snapshot.of("") + var idx = 1 + for ((key, value) in state.sequence) { + snapshot = snapshot.plusFacet("$OPEN$idx$CLOSE$key", value) + } + disk.writeDisk(snapshot, sub, call) + } + } + fun next( + roundtripKey: Roundtrip, + key: K, + roundtripValue: Roundtrip, + value: Cacheable + ): V { + if (state.readMode) { + val expected = state.sequence[state.count++] + val keyString = roundtripKey.serialize(key) + if (expected.first != keyString) { + throw Selfie.system.fs.assertFailed( + "vcr key mismatch at index ${state.count - 1}", expected, keyString) + } + return roundtripValue.parse(expected.second.valueString()) + } else { + val value = value.get() + state.sequence.add( + roundtripKey.serialize(key) to SnapshotValue.of(roundtripValue.serialize(value))) + return value + } + } + fun next(key: String, value: Cacheable): String = + next(Roundtrip.identity(), key, Roundtrip.identity(), value) + fun next(roundtripKey: Roundtrip, key: K, value: Cacheable): String = + next(roundtripKey, key, Roundtrip.identity(), value) + fun next(key: String, roundtripValue: Roundtrip, value: Cacheable): V = + next(Roundtrip.identity(), key, roundtripValue, value) + inline fun nextJson(key: K, value: Cacheable): V = + next(RoundtripJson.of(), key, RoundtripJson.of(), value) +} From fbedf0b731f77b4b992f435090f5f339a290522e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 20 Feb 2025 15:55:37 -0800 Subject: [PATCH 03/10] It was a mistake to treat the key as anything other than a string. --- .../kotlin/com/diffplug/selfie/VcrSelfie.kt | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt index 179f037c6..f1f71f027 100644 --- a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt +++ b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt @@ -70,33 +70,21 @@ class VcrSelfie( disk.writeDisk(snapshot, sub, call) } } - fun next( - roundtripKey: Roundtrip, - key: K, - roundtripValue: Roundtrip, - value: Cacheable - ): V { + fun next(key: String, roundtripValue: Roundtrip, value: Cacheable): V { if (state.readMode) { val expected = state.sequence[state.count++] - val keyString = roundtripKey.serialize(key) - if (expected.first != keyString) { + if (expected.first != key) { throw Selfie.system.fs.assertFailed( - "vcr key mismatch at index ${state.count - 1}", expected, keyString) + "vcr key mismatch at index ${state.count - 1}", expected.first, key) } return roundtripValue.parse(expected.second.valueString()) } else { val value = value.get() - state.sequence.add( - roundtripKey.serialize(key) to SnapshotValue.of(roundtripValue.serialize(value))) + state.sequence.add(key to SnapshotValue.of(roundtripValue.serialize(value))) return value } } - fun next(key: String, value: Cacheable): String = - next(Roundtrip.identity(), key, Roundtrip.identity(), value) - fun next(roundtripKey: Roundtrip, key: K, value: Cacheable): String = - next(roundtripKey, key, Roundtrip.identity(), value) - fun next(key: String, roundtripValue: Roundtrip, value: Cacheable): V = - next(Roundtrip.identity(), key, roundtripValue, value) - inline fun nextJson(key: K, value: Cacheable): V = - next(RoundtripJson.of(), key, RoundtripJson.of(), value) + fun next(key: String, value: Cacheable): String = next(key, Roundtrip.identity(), value) + inline fun nextJson(key: String, value: Cacheable): V = + next(key, RoundtripJson.of(), value) } From f5ed9ddac50a3ce1efd7423c90dc9022edc7b4ef Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 20 Feb 2025 16:17:50 -0800 Subject: [PATCH 04/10] Add `nextBinary` --- .../kotlin/com/diffplug/selfie/VcrSelfie.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt index f1f71f027..9d58d06a4 100644 --- a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt +++ b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt @@ -87,4 +87,22 @@ class VcrSelfie( fun next(key: String, value: Cacheable): String = next(key, Roundtrip.identity(), value) inline fun nextJson(key: String, value: Cacheable): V = next(key, RoundtripJson.of(), value) + + fun nextBinary(key: String, roundtripValue: Roundtrip, value: Cacheable): V { + if (state.readMode) { + val expected = state.sequence[state.count++] + if (expected.first != key) { + throw Selfie.system.fs.assertFailed( + "vcr key mismatch at index ${state.count - 1}", expected.first, key) + } + return roundtripValue.parse(expected.second.valueBinary()) + } else { + val value = value.get() + state.sequence.add(key to SnapshotValue.of(roundtripValue.serialize(value))) + return value + } + } + + fun nextBinary(key: String, value: Cacheable): ByteArray + = nextBinary(key, Roundtrip.identity(), value) } From 696891367b7934775138276c476fd937d20fa97f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 20 Feb 2025 21:40:20 -0800 Subject: [PATCH 05/10] Improve the VcrSelfie error messages. --- .../kotlin/com/diffplug/selfie/Mode.kt | 10 +++++-- .../kotlin/com/diffplug/selfie/VcrSelfie.kt | 17 ++++++----- .../selfie/guts/SnapshotNotEqualErrorMsg.kt | 8 ++--- .../guts/SnapshotNotEqualErrorMsgTest.kt | 30 +++++++++---------- 4 files changed, 35 insertions(+), 30 deletions(-) diff --git a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Mode.kt b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Mode.kt index 3f49888ab..a106cd43c 100644 --- a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Mode.kt +++ b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Mode.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023-2024 DiffPlug + * Copyright (C) 2023-2025 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -44,9 +44,11 @@ enum class Mode { internal fun msgSnapshotNotFoundNoSuchFile(file: TypedPath) = msg("Snapshot not found: no such file $file") internal fun msgSnapshotMismatch(expected: String, actual: String) = - msg(SnapshotNotEqualErrorMsg.forUnequalStrings(expected, actual)) + msg("Snapshot " + SnapshotNotEqualErrorMsg.forUnequalStrings(expected, actual)) internal fun msgSnapshotMismatchBinary(expected: ByteArray, actual: ByteArray) = msgSnapshotMismatch(expected.toQuotedPrintable(), actual.toQuotedPrintable()) + internal fun msgVcrKeyMismatch(key: String, expected: String, actual: String) = + msg("VCR key $key " + SnapshotNotEqualErrorMsg.forUnequalStrings(expected, actual)) private fun ByteArray.toQuotedPrintable(): String { val sb = StringBuilder() for (byte in this) { @@ -63,7 +65,9 @@ enum class Mode { when (this) { interactive -> "$headline\n" + - "‣ update this snapshot by adding `_TODO` to the function name\n" + + (if (headline.startsWith("Snapshot ")) + "‣ update this snapshot by adding `_TODO` to the function name\n" + else "") + "‣ update all snapshots in this file by adding `//selfieonce` or `//SELFIEWRITE`" readonly -> headline overwrite -> "$headline\n(didn't expect this to ever happen in overwrite mode)" diff --git a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt index 9d58d06a4..ae6c73367 100644 --- a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt +++ b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt @@ -70,12 +70,16 @@ class VcrSelfie( disk.writeDisk(snapshot, sub, call) } } + private fun keyMismatch(expected: String, actual: String): Throwable = + Selfie.system.fs.assertFailed( + Selfie.system.mode.msgVcrKeyMismatch("$sub[$OPEN${state.count}$CLOSE]", expected, actual), + expected, + actual) fun next(key: String, roundtripValue: Roundtrip, value: Cacheable): V { if (state.readMode) { val expected = state.sequence[state.count++] if (expected.first != key) { - throw Selfie.system.fs.assertFailed( - "vcr key mismatch at index ${state.count - 1}", expected.first, key) + throw keyMismatch(expected.first, key) } return roundtripValue.parse(expected.second.valueString()) } else { @@ -87,13 +91,11 @@ class VcrSelfie( fun next(key: String, value: Cacheable): String = next(key, Roundtrip.identity(), value) inline fun nextJson(key: String, value: Cacheable): V = next(key, RoundtripJson.of(), value) - fun nextBinary(key: String, roundtripValue: Roundtrip, value: Cacheable): V { if (state.readMode) { val expected = state.sequence[state.count++] if (expected.first != key) { - throw Selfie.system.fs.assertFailed( - "vcr key mismatch at index ${state.count - 1}", expected.first, key) + throw keyMismatch(expected.first, key) } return roundtripValue.parse(expected.second.valueBinary()) } else { @@ -102,7 +104,6 @@ class VcrSelfie( return value } } - - fun nextBinary(key: String, value: Cacheable): ByteArray - = nextBinary(key, Roundtrip.identity(), value) + fun nextBinary(key: String, value: Cacheable): ByteArray = + nextBinary(key, Roundtrip.identity(), value) } diff --git a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/guts/SnapshotNotEqualErrorMsg.kt b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/guts/SnapshotNotEqualErrorMsg.kt index 610c2ef2e..d4e2e53ed 100644 --- a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/guts/SnapshotNotEqualErrorMsg.kt +++ b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/guts/SnapshotNotEqualErrorMsg.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 DiffPlug + * Copyright (C) 2024-2025 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,7 +32,7 @@ object SnapshotNotEqualErrorMsg { actual.indexOf('\n', index).let { if (it == -1) actual.length else it } val expectedLine = expected.substring(index - columnNumber + 1, endOfLineExpected) val actualLine = actual.substring(index - columnNumber + 1, endOfLineActual) - return "Snapshot mismatch at L$lineNumber:C$columnNumber\n-$expectedLine\n+$actualLine" + return "mismatch at L$lineNumber:C$columnNumber\n-$expectedLine\n+$actualLine" } if (expectedChar == '\n') { lineNumber++ @@ -53,11 +53,11 @@ object SnapshotNotEqualErrorMsg { val endIdx = longer.indexOf('\n', endOfLineActual + 1).let { if (it == -1) longer.length else it } val line = longer.substring(endOfLineActual + 1, endIdx) - return "Snapshot mismatch at L${lineNumber+1}:C1 - line(s) ${if (added == "+") "added" else "removed"}\n${added}$line" + return "mismatch at L${lineNumber+1}:C1 - line(s) ${if (added == "+") "added" else "removed"}\n${added}$line" } else { val expectedLine = expected.substring(index - columnNumber + 1, endOfLineExpected) val actualLine = actual.substring(index - columnNumber + 1, endOfLineActual) - return "Snapshot mismatch at L$lineNumber:C$columnNumber\n-$expectedLine\n+$actualLine" + return "mismatch at L$lineNumber:C$columnNumber\n-$expectedLine\n+$actualLine" } } } diff --git a/jvm/selfie-lib/src/commonTest/kotlin/com/diffplug/selfie/guts/SnapshotNotEqualErrorMsgTest.kt b/jvm/selfie-lib/src/commonTest/kotlin/com/diffplug/selfie/guts/SnapshotNotEqualErrorMsgTest.kt index e47183e91..a563212f9 100644 --- a/jvm/selfie-lib/src/commonTest/kotlin/com/diffplug/selfie/guts/SnapshotNotEqualErrorMsgTest.kt +++ b/jvm/selfie-lib/src/commonTest/kotlin/com/diffplug/selfie/guts/SnapshotNotEqualErrorMsgTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 DiffPlug + * Copyright (C) 2024-2025 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,12 +22,12 @@ class SnapshotNotEqualErrorMsgTest { @Test fun errorLine1() { SnapshotNotEqualErrorMsg.forUnequalStrings("Testing 123", "Testing ABC") shouldBe - """Snapshot mismatch at L1:C9 + """mismatch at L1:C9 -Testing 123 +Testing ABC""" SnapshotNotEqualErrorMsg.forUnequalStrings("123 Testing", "ABC Testing") shouldBe - """Snapshot mismatch at L1:C1 + """mismatch at L1:C1 -123 Testing +ABC Testing""" } @@ -35,12 +35,12 @@ class SnapshotNotEqualErrorMsgTest { @Test fun errorLine2() { SnapshotNotEqualErrorMsg.forUnequalStrings("Line\nTesting 123", "Line\nTesting ABC") shouldBe - """Snapshot mismatch at L2:C9 + """mismatch at L2:C9 -Testing 123 +Testing ABC""" SnapshotNotEqualErrorMsg.forUnequalStrings("Line\n123 Testing", "Line\nABC Testing") shouldBe - """Snapshot mismatch at L2:C1 + """mismatch at L2:C1 -123 Testing +ABC Testing""" } @@ -48,11 +48,11 @@ class SnapshotNotEqualErrorMsgTest { @Test fun extraLine1() { SnapshotNotEqualErrorMsg.forUnequalStrings("123", "123ABC") shouldBe - """Snapshot mismatch at L1:C4 + """mismatch at L1:C4 -123 +123ABC""" SnapshotNotEqualErrorMsg.forUnequalStrings("123ABC", "123") shouldBe - """Snapshot mismatch at L1:C4 + """mismatch at L1:C4 -123ABC +123""" } @@ -60,11 +60,11 @@ class SnapshotNotEqualErrorMsgTest { @Test fun extraLine2() { SnapshotNotEqualErrorMsg.forUnequalStrings("line\n123", "line\n123ABC") shouldBe - """Snapshot mismatch at L2:C4 + """mismatch at L2:C4 -123 +123ABC""" SnapshotNotEqualErrorMsg.forUnequalStrings("line\n123ABC", "line\n123") shouldBe - """Snapshot mismatch at L2:C4 + """mismatch at L2:C4 -123ABC +123""" } @@ -72,26 +72,26 @@ class SnapshotNotEqualErrorMsgTest { @Test fun extraLine() { SnapshotNotEqualErrorMsg.forUnequalStrings("line", "line\nnext") shouldBe - """Snapshot mismatch at L2:C1 - line(s) added + """mismatch at L2:C1 - line(s) added +next""" SnapshotNotEqualErrorMsg.forUnequalStrings("line\nnext", "line") shouldBe - """Snapshot mismatch at L2:C1 - line(s) removed + """mismatch at L2:C1 - line(s) removed -next""" } @Test fun extraNewline() { SnapshotNotEqualErrorMsg.forUnequalStrings("line", "line\n") shouldBe - """Snapshot mismatch at L2:C1 - line(s) added + """mismatch at L2:C1 - line(s) added +""" SnapshotNotEqualErrorMsg.forUnequalStrings("line\n", "line") shouldBe - """Snapshot mismatch at L2:C1 - line(s) removed + """mismatch at L2:C1 - line(s) removed -""" SnapshotNotEqualErrorMsg.forUnequalStrings("", "\n") shouldBe - """Snapshot mismatch at L2:C1 - line(s) added + """mismatch at L2:C1 - line(s) added +""" SnapshotNotEqualErrorMsg.forUnequalStrings("\n", "") shouldBe - """Snapshot mismatch at L2:C1 - line(s) removed + """mismatch at L2:C1 - line(s) removed -""" } } From 06aa3eb65f233be4371941a358b18a72fdea9a54 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 20 Feb 2025 23:13:59 -0800 Subject: [PATCH 06/10] Add a `VcrBeta` opt-in annotation. --- .../kotlin/com/diffplug/selfie/Selfie.kt | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Selfie.kt b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Selfie.kt index bdc2b95e2..5623dc9a4 100644 --- a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Selfie.kt +++ b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Selfie.kt @@ -88,5 +88,20 @@ object Selfie { @JvmStatic fun cacheSelfieBinary(roundtrip: Roundtrip, toCache: Cacheable) = CacheSelfieBinary(deferredDiskStorage, roundtrip, toCache) - @JvmStatic fun vcrTestLocator(sub: String = "") = VcrSelfie.TestLocator(sub, deferredDiskStorage) + + /** + * Whichever file calls this method is where Selfie will look for `//selfieonce` comments to + * control whether the VCR is writing or reading. If the caller lives in a package called + * `selfie.*` it will keep looking up the stack trace until a caller is not inside `selfie.*`. + */ + @JvmStatic + @VcrBeta + fun vcrTestLocator(sub: String = "") = VcrSelfie.TestLocator(sub, deferredDiskStorage) } + +@RequiresOptIn( + level = RequiresOptIn.Level.WARNING, + message = "This API is in beta and may change in the future.") +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY) +annotation class VcrBeta From 414880bc6a033f8ad192671fc64b964c9f0356bd Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 20 Feb 2025 23:15:57 -0800 Subject: [PATCH 07/10] Improve the VcrSelfie to have better error messages. --- .../kotlin/com/diffplug/selfie/Mode.kt | 5 +++ .../kotlin/com/diffplug/selfie/VcrSelfie.kt | 40 +++++++++++-------- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Mode.kt b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Mode.kt index a106cd43c..39ec20726 100644 --- a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Mode.kt +++ b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Mode.kt @@ -49,6 +49,11 @@ enum class Mode { msgSnapshotMismatch(expected.toQuotedPrintable(), actual.toQuotedPrintable()) internal fun msgVcrKeyMismatch(key: String, expected: String, actual: String) = msg("VCR key $key " + SnapshotNotEqualErrorMsg.forUnequalStrings(expected, actual)) + internal fun msgVcrKeyUnread(expected: Int, actual: Int) = + msg("VCR entries unread - only $actual were read out of $expected") + internal fun msgVcrKeyUnderflow(expected: Int) = + msg( + "VCR entries exhausted - only $expected are available but you tried to read ${expected + 1}") private fun ByteArray.toQuotedPrintable(): String { val sb = StringBuilder() for (byte in this) { diff --git a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt index ae6c73367..ce4b69601 100644 --- a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt +++ b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt @@ -22,7 +22,8 @@ import com.diffplug.selfie.guts.recordCall private const val OPEN = "«" private const val CLOSE = "»" -class VcrSelfie( +class VcrSelfie +internal constructor( private val sub: String, private val call: CallStack, private val disk: DiskStorage, @@ -60,7 +61,10 @@ class VcrSelfie( } override fun close() { if (state.readMode) { - check(state.count == state.sequence.size) + if (state.sequence.size != state.count) { + throw Selfie.system.fs.assertFailed( + Selfie.system.mode.msgVcrKeyUnread(state.sequence.size, state.count)) + } } else { var snapshot = Snapshot.of("") var idx = 1 @@ -70,18 +74,24 @@ class VcrSelfie( disk.writeDisk(snapshot, sub, call) } } - private fun keyMismatch(expected: String, actual: String): Throwable = - Selfie.system.fs.assertFailed( - Selfie.system.mode.msgVcrKeyMismatch("$sub[$OPEN${state.count}$CLOSE]", expected, actual), - expected, - actual) + private fun nextValue(key: String): SnapshotValue { + val mode = Selfie.system.mode + val fs = Selfie.system.fs + if (state.sequence.size <= state.count) { + throw fs.assertFailed(mode.msgVcrKeyUnderflow(state.sequence.size)) + } + val expected = state.sequence[state.count++] + if (expected.first != key) { + throw fs.assertFailed( + mode.msgVcrKeyMismatch("$sub[$OPEN${state.count}$CLOSE]", expected.first, key), + expected.first, + key) + } + return expected.second + } fun next(key: String, roundtripValue: Roundtrip, value: Cacheable): V { if (state.readMode) { - val expected = state.sequence[state.count++] - if (expected.first != key) { - throw keyMismatch(expected.first, key) - } - return roundtripValue.parse(expected.second.valueString()) + return roundtripValue.parse(nextValue(key).valueString()) } else { val value = value.get() state.sequence.add(key to SnapshotValue.of(roundtripValue.serialize(value))) @@ -93,11 +103,7 @@ class VcrSelfie( next(key, RoundtripJson.of(), value) fun nextBinary(key: String, roundtripValue: Roundtrip, value: Cacheable): V { if (state.readMode) { - val expected = state.sequence[state.count++] - if (expected.first != key) { - throw keyMismatch(expected.first, key) - } - return roundtripValue.parse(expected.second.valueBinary()) + return roundtripValue.parse(nextValue(key).valueBinary()) } else { val value = value.get() state.sequence.add(key to SnapshotValue.of(roundtripValue.serialize(value))) From e66184900bece210923be1ff222e3c203e4cccdc Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 20 Feb 2025 23:27:36 -0800 Subject: [PATCH 08/10] Follow Kotlin standard for OptIn annotation. --- .../src/commonMain/kotlin/com/diffplug/selfie/Selfie.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Selfie.kt b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Selfie.kt index 5623dc9a4..7b40efca1 100644 --- a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Selfie.kt +++ b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Selfie.kt @@ -95,7 +95,7 @@ object Selfie { * `selfie.*` it will keep looking up the stack trace until a caller is not inside `selfie.*`. */ @JvmStatic - @VcrBeta + @ExperimentalSelfieVcr fun vcrTestLocator(sub: String = "") = VcrSelfie.TestLocator(sub, deferredDiskStorage) } @@ -104,4 +104,4 @@ object Selfie { message = "This API is in beta and may change in the future.") @Retention(AnnotationRetention.BINARY) @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY) -annotation class VcrBeta +annotation class ExperimentalSelfieVcr From aad41a4a14abfa1fb5fc27a738748f463b59a40e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 20 Feb 2025 23:27:49 -0800 Subject: [PATCH 09/10] Update changelog. --- jvm/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/jvm/CHANGELOG.md b/jvm/CHANGELOG.md index 52aa3248c..b0a9c0038 100644 --- a/jvm/CHANGELOG.md +++ b/jvm/CHANGELOG.md @@ -11,6 +11,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Added an entrypoint `Selfie.vcrTestLocator()` for the new `VcrSelfie` class for snapshotting and replaying network traffic. ([#517](https://github.com/diffplug/selfie/pull/517/files)) ### Fixed - Fixed a bug when saving facets containing keys with the `]` character ([#518](https://github.com/diffplug/selfie/pull/518)) From 292f6e87797ef5ec4990457e0f1b6b01399d9d85 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 20 Feb 2025 23:35:21 -0800 Subject: [PATCH 10/10] Standardize on a "frame" metaphor. --- .../kotlin/com/diffplug/selfie/Mode.kt | 12 ++--- .../kotlin/com/diffplug/selfie/VcrSelfie.kt | 49 ++++++++++--------- 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Mode.kt b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Mode.kt index 39ec20726..8b60b431a 100644 --- a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Mode.kt +++ b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Mode.kt @@ -47,13 +47,13 @@ enum class Mode { msg("Snapshot " + SnapshotNotEqualErrorMsg.forUnequalStrings(expected, actual)) internal fun msgSnapshotMismatchBinary(expected: ByteArray, actual: ByteArray) = msgSnapshotMismatch(expected.toQuotedPrintable(), actual.toQuotedPrintable()) - internal fun msgVcrKeyMismatch(key: String, expected: String, actual: String) = - msg("VCR key $key " + SnapshotNotEqualErrorMsg.forUnequalStrings(expected, actual)) - internal fun msgVcrKeyUnread(expected: Int, actual: Int) = - msg("VCR entries unread - only $actual were read out of $expected") - internal fun msgVcrKeyUnderflow(expected: Int) = + internal fun msgVcrMismatch(key: String, expected: String, actual: String) = + msg("VCR frame $key " + SnapshotNotEqualErrorMsg.forUnequalStrings(expected, actual)) + internal fun msgVcrUnread(expected: Int, actual: Int) = + msg("VCR frames unread - only $actual were read out of $expected") + internal fun msgVcrUnderflow(expected: Int) = msg( - "VCR entries exhausted - only $expected are available but you tried to read ${expected + 1}") + "VCR frames exhausted - only $expected are available but you tried to read ${expected + 1}") private fun ByteArray.toQuotedPrintable(): String { val sb = StringBuilder() for (byte in this) { diff --git a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt index ce4b69601..3de60f0ef 100644 --- a/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt +++ b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt @@ -34,8 +34,8 @@ internal constructor( } private class State(val readMode: Boolean) { - var count = 0 - val sequence = mutableListOf>() + var currentFrame = 0 + val frames = mutableListOf>() } private val state: State @@ -55,61 +55,66 @@ internal constructor( check(num == idx) ++idx val keyAfterNum = key.substring(nextClose + 1) - state.sequence.add(keyAfterNum to value) + state.frames.add(keyAfterNum to value) } } } override fun close() { if (state.readMode) { - if (state.sequence.size != state.count) { + if (state.frames.size != state.currentFrame) { throw Selfie.system.fs.assertFailed( - Selfie.system.mode.msgVcrKeyUnread(state.sequence.size, state.count)) + Selfie.system.mode.msgVcrUnread(state.frames.size, state.currentFrame)) } } else { var snapshot = Snapshot.of("") var idx = 1 - for ((key, value) in state.sequence) { + for ((key, value) in state.frames) { snapshot = snapshot.plusFacet("$OPEN$idx$CLOSE$key", value) } disk.writeDisk(snapshot, sub, call) } } - private fun nextValue(key: String): SnapshotValue { + private fun nextFrameValue(key: String): SnapshotValue { val mode = Selfie.system.mode val fs = Selfie.system.fs - if (state.sequence.size <= state.count) { - throw fs.assertFailed(mode.msgVcrKeyUnderflow(state.sequence.size)) + if (state.frames.size <= state.currentFrame) { + throw fs.assertFailed(mode.msgVcrUnderflow(state.frames.size)) } - val expected = state.sequence[state.count++] + val expected = state.frames[state.currentFrame++] if (expected.first != key) { throw fs.assertFailed( - mode.msgVcrKeyMismatch("$sub[$OPEN${state.count}$CLOSE]", expected.first, key), + mode.msgVcrMismatch("$sub[$OPEN${state.currentFrame}$CLOSE]", expected.first, key), expected.first, key) } return expected.second } - fun next(key: String, roundtripValue: Roundtrip, value: Cacheable): V { + fun nextFrame(key: String, roundtripValue: Roundtrip, value: Cacheable): V { if (state.readMode) { - return roundtripValue.parse(nextValue(key).valueString()) + return roundtripValue.parse(nextFrameValue(key).valueString()) } else { val value = value.get() - state.sequence.add(key to SnapshotValue.of(roundtripValue.serialize(value))) + state.frames.add(key to SnapshotValue.of(roundtripValue.serialize(value))) return value } } - fun next(key: String, value: Cacheable): String = next(key, Roundtrip.identity(), value) - inline fun nextJson(key: String, value: Cacheable): V = - next(key, RoundtripJson.of(), value) - fun nextBinary(key: String, roundtripValue: Roundtrip, value: Cacheable): V { + fun nextFrame(key: String, value: Cacheable): String = + nextFrame(key, Roundtrip.identity(), value) + inline fun nextFrameJson(key: String, value: Cacheable): V = + nextFrame(key, RoundtripJson.of(), value) + fun nextFrameBinary( + key: String, + roundtripValue: Roundtrip, + value: Cacheable + ): V { if (state.readMode) { - return roundtripValue.parse(nextValue(key).valueBinary()) + return roundtripValue.parse(nextFrameValue(key).valueBinary()) } else { val value = value.get() - state.sequence.add(key to SnapshotValue.of(roundtripValue.serialize(value))) + state.frames.add(key to SnapshotValue.of(roundtripValue.serialize(value))) return value } } - fun nextBinary(key: String, value: Cacheable): ByteArray = - nextBinary(key, Roundtrip.identity(), value) + fun nextFrameBinary(key: String, value: Cacheable): ByteArray = + nextFrameBinary(key, Roundtrip.identity(), value) }