diff --git a/jvm/CHANGELOG.md b/jvm/CHANGELOG.md index 52aa3248..b0a9c003 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)) 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 3f49888a..8b60b431 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,16 @@ 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 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 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) { @@ -63,7 +70,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/Selfie.kt b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Selfie.kt index 49b31e9c..7b40efca 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,20 @@ object Selfie { @JvmStatic fun cacheSelfieBinary(roundtrip: Roundtrip, toCache: Cacheable) = CacheSelfieBinary(deferredDiskStorage, roundtrip, toCache) + + /** + * 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 + @ExperimentalSelfieVcr + 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 ExperimentalSelfieVcr 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 00000000..3de60f0e --- /dev/null +++ b/jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt @@ -0,0 +1,120 @@ +/* + * 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 +internal constructor( + 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 currentFrame = 0 + val frames = 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.frames.add(keyAfterNum to value) + } + } + } + override fun close() { + if (state.readMode) { + if (state.frames.size != state.currentFrame) { + throw Selfie.system.fs.assertFailed( + Selfie.system.mode.msgVcrUnread(state.frames.size, state.currentFrame)) + } + } else { + var snapshot = Snapshot.of("") + var idx = 1 + for ((key, value) in state.frames) { + snapshot = snapshot.plusFacet("$OPEN$idx$CLOSE$key", value) + } + disk.writeDisk(snapshot, sub, call) + } + } + private fun nextFrameValue(key: String): SnapshotValue { + val mode = Selfie.system.mode + val fs = Selfie.system.fs + if (state.frames.size <= state.currentFrame) { + throw fs.assertFailed(mode.msgVcrUnderflow(state.frames.size)) + } + val expected = state.frames[state.currentFrame++] + if (expected.first != key) { + throw fs.assertFailed( + mode.msgVcrMismatch("$sub[$OPEN${state.currentFrame}$CLOSE]", expected.first, key), + expected.first, + key) + } + return expected.second + } + fun nextFrame(key: String, roundtripValue: Roundtrip, value: Cacheable): V { + if (state.readMode) { + return roundtripValue.parse(nextFrameValue(key).valueString()) + } else { + val value = value.get() + state.frames.add(key to SnapshotValue.of(roundtripValue.serialize(value))) + return value + } + } + 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(nextFrameValue(key).valueBinary()) + } else { + val value = value.get() + state.frames.add(key to SnapshotValue.of(roundtripValue.serialize(value))) + return value + } + } + fun nextFrameBinary(key: String, value: Cacheable): ByteArray = + nextFrameBinary(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 610c2ef2..d4e2e53e 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 e47183e9..a563212f 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 -""" } } 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 fcb7f19d..a01a6846 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