Skip to content

Commit 54d97cc

Browse files
authored
Add "VCR" functionality (#517)
2 parents 80f03be + 292f6e8 commit 54d97cc

File tree

7 files changed

+175
-25
lines changed

7 files changed

+175
-25
lines changed

jvm/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
1111
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1212

1313
## [Unreleased]
14+
### Added
15+
- 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))
1416
### Fixed
1517
- Fixed a bug when saving facets containing keys with the `]` character ([#518](https://github.com/diffplug/selfie/pull/518))
1618

jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Mode.kt

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2023-2024 DiffPlug
2+
* Copyright (C) 2023-2025 DiffPlug
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -44,9 +44,16 @@ enum class Mode {
4444
internal fun msgSnapshotNotFoundNoSuchFile(file: TypedPath) =
4545
msg("Snapshot not found: no such file $file")
4646
internal fun msgSnapshotMismatch(expected: String, actual: String) =
47-
msg(SnapshotNotEqualErrorMsg.forUnequalStrings(expected, actual))
47+
msg("Snapshot " + SnapshotNotEqualErrorMsg.forUnequalStrings(expected, actual))
4848
internal fun msgSnapshotMismatchBinary(expected: ByteArray, actual: ByteArray) =
4949
msgSnapshotMismatch(expected.toQuotedPrintable(), actual.toQuotedPrintable())
50+
internal fun msgVcrMismatch(key: String, expected: String, actual: String) =
51+
msg("VCR frame $key " + SnapshotNotEqualErrorMsg.forUnequalStrings(expected, actual))
52+
internal fun msgVcrUnread(expected: Int, actual: Int) =
53+
msg("VCR frames unread - only $actual were read out of $expected")
54+
internal fun msgVcrUnderflow(expected: Int) =
55+
msg(
56+
"VCR frames exhausted - only $expected are available but you tried to read ${expected + 1}")
5057
private fun ByteArray.toQuotedPrintable(): String {
5158
val sb = StringBuilder()
5259
for (byte in this) {
@@ -63,7 +70,9 @@ enum class Mode {
6370
when (this) {
6471
interactive ->
6572
"$headline\n" +
66-
"‣ update this snapshot by adding `_TODO` to the function name\n" +
73+
(if (headline.startsWith("Snapshot "))
74+
"‣ update this snapshot by adding `_TODO` to the function name\n"
75+
else "") +
6776
"‣ update all snapshots in this file by adding `//selfieonce` or `//SELFIEWRITE`"
6877
readonly -> headline
6978
overwrite -> "$headline\n(didn't expect this to ever happen in overwrite mode)"

jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Selfie.kt

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2023-2024 DiffPlug
2+
* Copyright (C) 2023-2025 DiffPlug
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -88,4 +88,20 @@ object Selfie {
8888
@JvmStatic
8989
fun <T> cacheSelfieBinary(roundtrip: Roundtrip<T, ByteArray>, toCache: Cacheable<T>) =
9090
CacheSelfieBinary<T>(deferredDiskStorage, roundtrip, toCache)
91+
92+
/**
93+
* Whichever file calls this method is where Selfie will look for `//selfieonce` comments to
94+
* control whether the VCR is writing or reading. If the caller lives in a package called
95+
* `selfie.*` it will keep looking up the stack trace until a caller is not inside `selfie.*`.
96+
*/
97+
@JvmStatic
98+
@ExperimentalSelfieVcr
99+
fun vcrTestLocator(sub: String = "") = VcrSelfie.TestLocator(sub, deferredDiskStorage)
91100
}
101+
102+
@RequiresOptIn(
103+
level = RequiresOptIn.Level.WARNING,
104+
message = "This API is in beta and may change in the future.")
105+
@Retention(AnnotationRetention.BINARY)
106+
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY)
107+
annotation class ExperimentalSelfieVcr
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
* Copyright (C) 2025 DiffPlug
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.diffplug.selfie
17+
18+
import com.diffplug.selfie.guts.CallStack
19+
import com.diffplug.selfie.guts.DiskStorage
20+
import com.diffplug.selfie.guts.recordCall
21+
22+
private const val OPEN = "«"
23+
private const val CLOSE = "»"
24+
25+
class VcrSelfie
26+
internal constructor(
27+
private val sub: String,
28+
private val call: CallStack,
29+
private val disk: DiskStorage,
30+
) : AutoCloseable {
31+
class TestLocator internal constructor(private val sub: String, private val disk: DiskStorage) {
32+
private val call = recordCall(false)
33+
fun createVcr() = VcrSelfie(sub, call, disk)
34+
}
35+
36+
private class State(val readMode: Boolean) {
37+
var currentFrame = 0
38+
val frames = mutableListOf<Pair<String, SnapshotValue>>()
39+
}
40+
private val state: State
41+
42+
init {
43+
val canWrite = Selfie.system.mode.canWrite(isTodo = false, call, Selfie.system)
44+
state = State(readMode = !canWrite)
45+
if (state.readMode) {
46+
val snapshot =
47+
disk.readDisk(sub, call)
48+
?: throw Selfie.system.fs.assertFailed(Selfie.system.mode.msgSnapshotNotFound())
49+
var idx = 1
50+
for ((key, value) in snapshot.facets) {
51+
check(key.startsWith(OPEN))
52+
val nextClose = key.indexOf(CLOSE)
53+
check(nextClose != -1)
54+
val num = key.substring(OPEN.length, nextClose).toInt()
55+
check(num == idx)
56+
++idx
57+
val keyAfterNum = key.substring(nextClose + 1)
58+
state.frames.add(keyAfterNum to value)
59+
}
60+
}
61+
}
62+
override fun close() {
63+
if (state.readMode) {
64+
if (state.frames.size != state.currentFrame) {
65+
throw Selfie.system.fs.assertFailed(
66+
Selfie.system.mode.msgVcrUnread(state.frames.size, state.currentFrame))
67+
}
68+
} else {
69+
var snapshot = Snapshot.of("")
70+
var idx = 1
71+
for ((key, value) in state.frames) {
72+
snapshot = snapshot.plusFacet("$OPEN$idx$CLOSE$key", value)
73+
}
74+
disk.writeDisk(snapshot, sub, call)
75+
}
76+
}
77+
private fun nextFrameValue(key: String): SnapshotValue {
78+
val mode = Selfie.system.mode
79+
val fs = Selfie.system.fs
80+
if (state.frames.size <= state.currentFrame) {
81+
throw fs.assertFailed(mode.msgVcrUnderflow(state.frames.size))
82+
}
83+
val expected = state.frames[state.currentFrame++]
84+
if (expected.first != key) {
85+
throw fs.assertFailed(
86+
mode.msgVcrMismatch("$sub[$OPEN${state.currentFrame}$CLOSE]", expected.first, key),
87+
expected.first,
88+
key)
89+
}
90+
return expected.second
91+
}
92+
fun <V> nextFrame(key: String, roundtripValue: Roundtrip<V, String>, value: Cacheable<V>): V {
93+
if (state.readMode) {
94+
return roundtripValue.parse(nextFrameValue(key).valueString())
95+
} else {
96+
val value = value.get()
97+
state.frames.add(key to SnapshotValue.of(roundtripValue.serialize(value)))
98+
return value
99+
}
100+
}
101+
fun nextFrame(key: String, value: Cacheable<String>): String =
102+
nextFrame(key, Roundtrip.identity(), value)
103+
inline fun <reified V> nextFrameJson(key: String, value: Cacheable<V>): V =
104+
nextFrame(key, RoundtripJson.of<V>(), value)
105+
fun <V> nextFrameBinary(
106+
key: String,
107+
roundtripValue: Roundtrip<V, ByteArray>,
108+
value: Cacheable<V>
109+
): V {
110+
if (state.readMode) {
111+
return roundtripValue.parse(nextFrameValue(key).valueBinary())
112+
} else {
113+
val value = value.get()
114+
state.frames.add(key to SnapshotValue.of(roundtripValue.serialize(value)))
115+
return value
116+
}
117+
}
118+
fun <V> nextFrameBinary(key: String, value: Cacheable<ByteArray>): ByteArray =
119+
nextFrameBinary(key, Roundtrip.identity(), value)
120+
}

jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/guts/SnapshotNotEqualErrorMsg.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2024 DiffPlug
2+
* Copyright (C) 2024-2025 DiffPlug
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -32,7 +32,7 @@ object SnapshotNotEqualErrorMsg {
3232
actual.indexOf('\n', index).let { if (it == -1) actual.length else it }
3333
val expectedLine = expected.substring(index - columnNumber + 1, endOfLineExpected)
3434
val actualLine = actual.substring(index - columnNumber + 1, endOfLineActual)
35-
return "Snapshot mismatch at L$lineNumber:C$columnNumber\n-$expectedLine\n+$actualLine"
35+
return "mismatch at L$lineNumber:C$columnNumber\n-$expectedLine\n+$actualLine"
3636
}
3737
if (expectedChar == '\n') {
3838
lineNumber++
@@ -53,11 +53,11 @@ object SnapshotNotEqualErrorMsg {
5353
val endIdx =
5454
longer.indexOf('\n', endOfLineActual + 1).let { if (it == -1) longer.length else it }
5555
val line = longer.substring(endOfLineActual + 1, endIdx)
56-
return "Snapshot mismatch at L${lineNumber+1}:C1 - line(s) ${if (added == "+") "added" else "removed"}\n${added}$line"
56+
return "mismatch at L${lineNumber+1}:C1 - line(s) ${if (added == "+") "added" else "removed"}\n${added}$line"
5757
} else {
5858
val expectedLine = expected.substring(index - columnNumber + 1, endOfLineExpected)
5959
val actualLine = actual.substring(index - columnNumber + 1, endOfLineActual)
60-
return "Snapshot mismatch at L$lineNumber:C$columnNumber\n-$expectedLine\n+$actualLine"
60+
return "mismatch at L$lineNumber:C$columnNumber\n-$expectedLine\n+$actualLine"
6161
}
6262
}
6363
}
Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2024 DiffPlug
2+
* Copyright (C) 2024-2025 DiffPlug
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -22,76 +22,76 @@ class SnapshotNotEqualErrorMsgTest {
2222
@Test
2323
fun errorLine1() {
2424
SnapshotNotEqualErrorMsg.forUnequalStrings("Testing 123", "Testing ABC") shouldBe
25-
"""Snapshot mismatch at L1:C9
25+
"""mismatch at L1:C9
2626
-Testing 123
2727
+Testing ABC"""
2828

2929
SnapshotNotEqualErrorMsg.forUnequalStrings("123 Testing", "ABC Testing") shouldBe
30-
"""Snapshot mismatch at L1:C1
30+
"""mismatch at L1:C1
3131
-123 Testing
3232
+ABC Testing"""
3333
}
3434

3535
@Test
3636
fun errorLine2() {
3737
SnapshotNotEqualErrorMsg.forUnequalStrings("Line\nTesting 123", "Line\nTesting ABC") shouldBe
38-
"""Snapshot mismatch at L2:C9
38+
"""mismatch at L2:C9
3939
-Testing 123
4040
+Testing ABC"""
4141

4242
SnapshotNotEqualErrorMsg.forUnequalStrings("Line\n123 Testing", "Line\nABC Testing") shouldBe
43-
"""Snapshot mismatch at L2:C1
43+
"""mismatch at L2:C1
4444
-123 Testing
4545
+ABC Testing"""
4646
}
4747

4848
@Test
4949
fun extraLine1() {
5050
SnapshotNotEqualErrorMsg.forUnequalStrings("123", "123ABC") shouldBe
51-
"""Snapshot mismatch at L1:C4
51+
"""mismatch at L1:C4
5252
-123
5353
+123ABC"""
5454
SnapshotNotEqualErrorMsg.forUnequalStrings("123ABC", "123") shouldBe
55-
"""Snapshot mismatch at L1:C4
55+
"""mismatch at L1:C4
5656
-123ABC
5757
+123"""
5858
}
5959

6060
@Test
6161
fun extraLine2() {
6262
SnapshotNotEqualErrorMsg.forUnequalStrings("line\n123", "line\n123ABC") shouldBe
63-
"""Snapshot mismatch at L2:C4
63+
"""mismatch at L2:C4
6464
-123
6565
+123ABC"""
6666
SnapshotNotEqualErrorMsg.forUnequalStrings("line\n123ABC", "line\n123") shouldBe
67-
"""Snapshot mismatch at L2:C4
67+
"""mismatch at L2:C4
6868
-123ABC
6969
+123"""
7070
}
7171

7272
@Test
7373
fun extraLine() {
7474
SnapshotNotEqualErrorMsg.forUnequalStrings("line", "line\nnext") shouldBe
75-
"""Snapshot mismatch at L2:C1 - line(s) added
75+
"""mismatch at L2:C1 - line(s) added
7676
+next"""
7777
SnapshotNotEqualErrorMsg.forUnequalStrings("line\nnext", "line") shouldBe
78-
"""Snapshot mismatch at L2:C1 - line(s) removed
78+
"""mismatch at L2:C1 - line(s) removed
7979
-next"""
8080
}
8181

8282
@Test
8383
fun extraNewline() {
8484
SnapshotNotEqualErrorMsg.forUnequalStrings("line", "line\n") shouldBe
85-
"""Snapshot mismatch at L2:C1 - line(s) added
85+
"""mismatch at L2:C1 - line(s) added
8686
+"""
8787
SnapshotNotEqualErrorMsg.forUnequalStrings("line\n", "line") shouldBe
88-
"""Snapshot mismatch at L2:C1 - line(s) removed
88+
"""mismatch at L2:C1 - line(s) removed
8989
-"""
9090
SnapshotNotEqualErrorMsg.forUnequalStrings("", "\n") shouldBe
91-
"""Snapshot mismatch at L2:C1 - line(s) added
91+
"""mismatch at L2:C1 - line(s) added
9292
+"""
9393
SnapshotNotEqualErrorMsg.forUnequalStrings("\n", "") shouldBe
94-
"""Snapshot mismatch at L2:C1 - line(s) removed
94+
"""mismatch at L2:C1 - line(s) removed
9595
-"""
9696
}
9797
}

jvm/selfie-lib/src/jvmMain/kotlin/com/diffplug/selfie/guts/WriteTracker.jvm.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2024 DiffPlug
2+
* Copyright (C) 2024-2025 DiffPlug
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -55,7 +55,10 @@ actual data class CallLocation(
5555
/** Generates a CallLocation and the CallStack behind it. */
5656
internal actual fun recordCall(callerFileOnly: Boolean): CallStack =
5757
StackWalker.getInstance().walk { frames ->
58-
val framesWithDrop = frames.dropWhile { it.className.startsWith("com.diffplug.selfie") }
58+
val framesWithDrop =
59+
frames.dropWhile {
60+
it.className.startsWith("com.diffplug.selfie.") || it.className.startsWith("selfie.")
61+
}
5962
if (callerFileOnly) {
6063
val caller =
6164
framesWithDrop

0 commit comments

Comments
 (0)