Skip to content
2 changes: 2 additions & 0 deletions jvm/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
15 changes: 12 additions & 3 deletions jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Mode.kt
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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)"
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -88,4 +88,20 @@ object Selfie {
@JvmStatic
fun <T> cacheSelfieBinary(roundtrip: Roundtrip<T, ByteArray>, toCache: Cacheable<T>) =
CacheSelfieBinary<T>(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
120 changes: 120 additions & 0 deletions jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/VcrSelfie.kt
Original file line number Diff line number Diff line change
@@ -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<Pair<String, SnapshotValue>>()
}
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 <V> nextFrame(key: String, roundtripValue: Roundtrip<V, String>, value: Cacheable<V>): 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>): String =
nextFrame(key, Roundtrip.identity(), value)
inline fun <reified V> nextFrameJson(key: String, value: Cacheable<V>): V =
nextFrame(key, RoundtripJson.of<V>(), value)
fun <V> nextFrameBinary(
key: String,
roundtripValue: Roundtrip<V, ByteArray>,
value: Cacheable<V>
): 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 <V> nextFrameBinary(key: String, value: Cacheable<ByteArray>): ByteArray =
nextFrameBinary(key, Roundtrip.identity(), value)
}
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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++
Expand All @@ -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"
}
}
}
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -22,76 +22,76 @@ 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"""
}

@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"""
}

@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"""
}

@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"""
}

@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
-"""
}
}
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand Down