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

Add "VCR" functionality #517

Merged
merged 11 commits into from
Feb 21, 2025
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