Skip to content

Commit

Permalink
Add support for binary via base64 (#108)
Browse files Browse the repository at this point in the history
  • Loading branch information
nedtwigg authored Jan 18, 2024
2 parents e139845 + b8ada40 commit c848531
Show file tree
Hide file tree
Showing 10 changed files with 194 additions and 48 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ 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
- Full support for binary snapshots. ([#108](https://github.com/diffplug/selfie/pull/108))
### Fixed
- Groovy multiline string values just go into `"` strings instead of `"""` until we have a chance to implement them properly. ([#107](https://github.com/diffplug/selfie/pull/107))

## [0.3.0] - 2024-01-17
### Added
Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ Snapshot testing is the [fastest and most precise mechanism to record and specif

Robots are writing their own code. Are you still writing assertions by hand?

- *Almost* production ready for [JVM](https://selfie.diffplug.com/jvm).
- see [the changelog](CHANGELOG.md) to see the remaining "known broken" items.
- Production ready for [JVM](https://selfie.diffplug.com/jvm).
- Help wanted using Kotlin Multiplatform for js and wasm (node and browser) ([#84](https://github.com/diffplug/selfie/issues/84)).
- Help also wanted for non-Kotlin platforms such as python and go ([#85](https://github.com/diffplug/selfie/issues/85)).

Expand Down
26 changes: 16 additions & 10 deletions selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Selfie.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import com.diffplug.selfie.guts.LiteralValue
import com.diffplug.selfie.guts.SnapshotStorage
import com.diffplug.selfie.guts.initStorage
import com.diffplug.selfie.guts.recordCall
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.jvm.JvmOverloads
import kotlin.jvm.JvmStatic

Expand Down Expand Up @@ -93,12 +95,14 @@ object Selfie {
fun facet(facet: String) = LiteralStringSelfie(actual, listOf(facet))
/** Extract a multiple facets from a snapshot in order to do an inline snapshot. */
fun facets(vararg facets: String) = LiteralStringSelfie(actual, facets.toList())

@OptIn(ExperimentalEncodingApi::class)
private fun actualString(): String {
if (actual.facets.isEmpty() || onlyFacets?.size == 1) {
// single value doesn't have to worry about escaping at all
val onlyValue = actual.subjectOrFacet(onlyFacets?.first() ?: "")
return if (onlyValue.isBinary) {
TODO("BASE64")
Base64.Mime.encode(onlyValue.valueBinary()).replace("\r", "")
} else onlyValue.valueString()
} else {
return serializeOnlyFacets(
Expand Down Expand Up @@ -203,20 +207,22 @@ object Selfie {
* missing facets.
*/
private fun serializeOnlyFacets(snapshot: Snapshot, keys: Collection<String>): String {
val buf = StringBuilder()
val writer = StringWriter { buf.append(it) }
val writer = StringBuilder()
for (key in keys) {
if (key.isEmpty()) {
SnapshotFile.writeValue(writer, snapshot.subjectOrFacet(key))
SnapshotFile.writeEntry(writer, "", null, snapshot.subjectOrFacet(key))
} else {
snapshot.subjectOrFacetMaybe(key)?.let {
SnapshotFile.writeKey(writer, "", key)
SnapshotFile.writeValue(writer, it)
}
snapshot.subjectOrFacetMaybe(key)?.let { SnapshotFile.writeEntry(writer, "", key, it) }
}
}
buf.setLength(buf.length - 1)
return buf.toString()
val EMPTY_KEY_AND_FACET = "╔═ ═╗\n"
return if (writer.startsWith(EMPTY_KEY_AND_FACET)) {
// this codepath is triggered by the `key.isEmpty()` line above
writer.subSequence(EMPTY_KEY_AND_FACET.length, writer.length - 1).toString()
} else {
writer.setLength(writer.length - 1)
writer.toString()
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
*/
package com.diffplug.selfie

import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.jvm.JvmStatic

class ParseException private constructor(val line: Int, message: String?, cause: Throwable?) :
Expand Down Expand Up @@ -136,23 +138,16 @@ class SnapshotFile {
// this will probably become `<String, JsonObject>` we'll cross that bridge when we get to it
var metadata: Map.Entry<String, String>? = null
var snapshots = ArrayMap.empty<String, Snapshot>()
fun serialize(valueWriterRaw: StringWriter) {
val valueWriter =
if (unixNewlines) valueWriterRaw
else StringWriter { valueWriterRaw.write(it.efficientReplace("\n", "\r\n")) }
metadata?.let {
writeKey(valueWriter, "📷 ${it.key}", null)
writeValue(valueWriter, SnapshotValue.of(it.value))
}
fun serialize(valueWriterRaw: Appendable) {
val valueWriter = if (unixNewlines) valueWriterRaw else ConvertToWindowsNewlines(valueWriterRaw)
metadata?.let { writeEntry(valueWriter, "📷 ${it.key}", null, SnapshotValue.of(it.value)) }
snapshots.entries.forEach { entry ->
writeKey(valueWriter, entry.key, null)
writeValue(valueWriter, entry.value.subject)
writeEntry(valueWriter, entry.key, null, entry.value.subject)
for (facet in entry.value.facets.entries) {
writeKey(valueWriter, entry.key, facet.key)
writeValue(valueWriter, facet.value)
writeEntry(valueWriter, entry.key, facet.key, facet.value)
}
}
writeKey(valueWriter, "", "end of file")
writeEntry(valueWriter, "", "end of file", SnapshotValue.of(""))
}

var wasSetAtTestTime: Boolean = false
Expand Down Expand Up @@ -198,27 +193,44 @@ class SnapshotFile {
result.unixNewlines = unixNewlines
return result
}
internal fun writeKey(valueWriter: StringWriter, key: String, facet: String?) {
valueWriter.write("╔═ ")
valueWriter.write(SnapshotValueReader.nameEsc.escape(key))

@OptIn(ExperimentalEncodingApi::class)
internal fun writeEntry(
valueWriter: Appendable,
key: String,
facet: String?,
value: SnapshotValue
) {
valueWriter.append("╔═ ")
valueWriter.append(SnapshotValueReader.nameEsc.escape(key))
if (facet != null) {
valueWriter.write("[")
valueWriter.write(SnapshotValueReader.nameEsc.escape(facet))
valueWriter.write("]")
valueWriter.append("[")
valueWriter.append(SnapshotValueReader.nameEsc.escape(facet))
valueWriter.append("]")
}
valueWriter.write(" ═╗\n")
}
internal fun writeValue(valueWriter: StringWriter, value: SnapshotValue) {
valueWriter.append(" ═╗")
if (value.isBinary) {
valueWriter.append(" base64 length ")
valueWriter.append(value.valueBinary().size.toString())
valueWriter.append(" bytes")
}
valueWriter.append("\n")

if (key.isEmpty() && facet == "end of file") {
return
}

if (value.isBinary) {
TODO("BASE64")
val escaped = Base64.Mime.encode(value.valueBinary())
valueWriter.append(escaped.efficientReplace("\r", ""))
} else {
val escaped =
SnapshotValueReader.bodyEsc
.escape(value.valueString())
.efficientReplace("\n", "\n\uD801\uDF41")
valueWriter.write(escaped)
valueWriter.write("\n")
valueWriter.append(escaped)
}
valueWriter.append("\n")
}
}
}
Expand Down Expand Up @@ -250,7 +262,7 @@ class SnapshotReader(val valueReader: SnapshotValueReader) {
val facetEndIdx = nextKey.indexOf(']', facetIdx + 1)
require(facetEndIdx != -1) { "Missing ] in $nextKey" }
val facetName = nextKey.substring(facetIdx + 1, facetEndIdx)
snapshot = snapshot.plusFacet(facetName, valueReader.nextValue().valueString())
snapshot = snapshot.plusFacet(facetName, valueReader.nextValue())
}
}
fun skipSnapshot() {
Expand All @@ -273,9 +285,11 @@ class SnapshotValueReader(val lineReader: LineReader) {
}

/** Reads the next value. */
@OptIn(ExperimentalEncodingApi::class)
fun nextValue(): SnapshotValue {
// validate key
nextKey()
val isBase64 = nextLine()!!.contains(FLAG_BASE64)
resetLine()

// read value
Expand All @@ -289,12 +303,14 @@ class SnapshotValueReader(val lineReader: LineReader) {
}
buffer.append('\n')
}
return SnapshotValue.of(
val stringValue =
if (buffer.isEmpty()) ""
else {
buffer.setLength(buffer.length - 1)
bodyEsc.unescape(buffer.toString())
})
}
return if (isBase64) SnapshotValue.of(Base64.Mime.decode(stringValue))
else SnapshotValue.of(stringValue)
}

/** Same as nextValue, but faster. */
Expand Down Expand Up @@ -356,6 +372,7 @@ class SnapshotValueReader(val lineReader: LineReader) {
private const val KEY_FIRST_CHAR = ''
private const val KEY_START = "╔═ "
private const val KEY_END = " ═╗"
private const val FLAG_BASE64 = " ═╗ base64"

/**
* https://github.com/diffplug/selfie/blob/main/selfie-lib/src/commonTest/resources/com/diffplug/selfie/scenarios_and_lenses.ss
Expand All @@ -377,6 +394,22 @@ expect class LineReader {
fun forBinary(content: ByteArray): LineReader
}
}
fun interface StringWriter {
fun write(string: String)

internal class ConvertToWindowsNewlines(val sink: Appendable) : Appendable {
override fun append(value: Char): Appendable {
if (value != '\n') sink.append(value)
else {
sink.append('\r')
sink.append('\n')
}
return this
}
override fun append(value: CharSequence?): Appendable {
value?.let { sink.append(it.toString().efficientReplace("\n", "\r\n")) }
return this
}
override fun append(value: CharSequence?, startIndex: Int, endIndex: Int): Appendable {
value?.let { sink.append(it.substring(startIndex, endIndex).efficientReplace("\n", "\r\n")) }
return this
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 DiffPlug
* Copyright (C) 2023-2024 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 @@ -72,8 +72,8 @@ class SnapshotFileTest {
"Apple",
Snapshot.of("Granny Smith").plusFacet("color", "green").plusFacet("crisp", "yes"))
underTest.snapshots = underTest.snapshots.plus("Orange", Snapshot.of("Orange"))
val buffer = StringBuffer()
underTest.serialize { line -> buffer.append(line) }
val buffer = StringBuilder()
underTest.serialize(buffer)
buffer.toString() shouldBe
"""
╔═ 📷 com.acme.AcmeTest ═╗
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 DiffPlug
* Copyright (C) 2023-2024 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,4 +44,30 @@ class SnapshotReaderTest {
reader.nextSnapshot() shouldBe Snapshot.of("Orange")
reader.peekKey() shouldBe null
}

@Test
fun binary() {
val reader =
SnapshotReader(
SnapshotValueReader.of(
"""
╔═ Apple ═╗
Apple
╔═ Apple[color] ═╗ base64 length 3 bytes
c2Fk
╔═ Apple[crisp] ═╗
yes
╔═ Orange ═╗ base64 length 3 bytes
c2Fk
"""
.trimIndent()))
reader.peekKey() shouldBe "Apple"
reader.peekKey() shouldBe "Apple"
reader.nextSnapshot() shouldBe
Snapshot.of("Apple").plusFacet("color", "sad".toByteArray()).plusFacet("crisp", "yes")
reader.peekKey() shouldBe "Orange"
reader.peekKey() shouldBe "Orange"
reader.nextSnapshot() shouldBe Snapshot.of("sad".toByteArray())
reader.peekKey() shouldBe null
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 DiffPlug
* Copyright (C) 2023-2024 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 @@ -163,4 +163,13 @@ class SnapshotValueReaderTest {
reader.skipValue()
}
}

@Test
fun binary() {
val reader = SnapshotValueReader.of("""╔═ Apple ═╗ base64 length 3 bytes
c2Fk
""")
reader.peekKey() shouldBe "Apple"
reader.nextValue().valueBinary() shouldBe "sad".toByteArray()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ internal class ClassProgress(val parent: Progress, val className: String) {
parent.markPathAsWritten(parent.layout.snapshotPathForClass(className))
Files.createDirectories(snapshotPath.toPath().parent)
Files.newBufferedWriter(snapshotPath.toPath(), StandardCharsets.UTF_8).use { writer ->
file!!.serialize(writer::write)
file!!.serialize(writer)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Copyright (C) 2024 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.junit5

import kotlin.test.Test
import org.junit.jupiter.api.MethodOrderer
import org.junit.jupiter.api.Order
import org.junit.jupiter.api.TestMethodOrder
import org.junitpioneer.jupiter.DisableIfTestFails

@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
@DisableIfTestFails
class BinaryTest : Harness("undertest-junit5") {
@Test @Order(1)
fun readFailsBecauseTodo() {
gradleReadSSFail()
}

@Test @Order(2)
fun writeSucceeds() {
gradleWriteSS()
}

@Test @Order(3)
fun nowReadSucceeds() {
gradleReadSS()
}

@Test @Order(4)
fun cleanup() {
ut_mirror().restoreFromGit()
ut_snapshot().deleteIfExists()
}
}
Loading

0 comments on commit c848531

Please sign in to comment.