Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Unexpected snapshot errors when snapshotting with lots of newlines #126

@nedtwigg

Description

@nedtwigg

Thanks so much for the library! I'm a big fan of snapshot testing, I've been baffled that java doesn't have one, and I think the high-level design of yours is fantastic! When I first tried it I got stuck for a while. Here was the behavior I was seeing

  • tests were passing locally
  • failing on CI with this error
  au.com.origin.snapshots.exceptions.LogGithubIssueException: Corrupt Snapshot (REGEX matches = 0): possibly due to manual editing or our REGEX failing
  Possible Solutions
  1. Ensure you have not accidentally manually edited the snapshot file!
  2. Compare the snapshot with GIT history
  
  *** This exception should never be thrown ***

I dug around and found this eventually

if (it.contains(SnapshotFile.SPLIT_STRING)) {
log.warn(
"Found 3 consecutive lines in your snapshot \\n\\n\\n. This sequence is reserved as the snapshot separator - replacing with \\n.\\n.\\n");
return it.replaceAll(SnapshotFile.SPLIT_STRING, "\n.\n.\n");
}

And it so happens that I was snapshotting input that started and ended with lots of newlines. So I tried adding a trim() before I passed my data to you and that fixed it.

IMO, it's a big deal to mangle the snapshot data - that's the most sacred part of a snapshot library! I think it's really important to pick a lossless encoding function. trim() works well enough for me for now so I'm not going to take the time to contribute a PR, but I can offer up this little hunk of code that I've used in several projects for handling problems of this sort (lossless roundtrip encoding of text in text). Feel free to use it or take a different approach :)

/**
 * If your escape policy is "'123", it means this:
 *
 * ```
 * abc->abc
 * 123->'1'2'3
 * I won't->I won''t
 * ```
 */
class PerCharacterEscaper
/**
 * The first character in the string will be uses as the escape character, and all characters will
 * be escaped.
 */
private constructor(
		private val escapeCodePoint: Int,
		private val escapedCodePoints: IntArray,
		private val escapedByCodePoints: IntArray
) : Converter<String, String>() {
	fun needsEscaping(input: String): Boolean {
		return firstOffsetNeedingEscape(input) != -1
	}

	private fun firstOffsetNeedingEscape(input: String): Int {
		val length = input.length
		var firstOffsetNeedingEscape = -1
		var offset = 0
		outer@ while (offset < length) {
			val codepoint = input.codePointAt(offset)
			for (escaped in escapedCodePoints) {
				if (codepoint == escaped) {
					firstOffsetNeedingEscape = offset
					break@outer
				}
			}
			offset += Character.charCount(codepoint)
		}
		return firstOffsetNeedingEscape
	}

	fun escapeCodePoint(): Int {
		return escapeCodePoint
	}

	override fun doForward(input: String): String {
		val noEscapes = firstOffsetNeedingEscape(input)
		return if (noEscapes == -1) {
			input
		} else {
			val length = input.length
			val needsEscapes = length - noEscapes
			val builder = StringBuilder(noEscapes + 4 + needsEscapes * 5 / 4)
			builder.append(input, 0, noEscapes)
			var offset = noEscapes
			while (offset < length) {
				val codepoint = input.codePointAt(offset)
				offset += Character.charCount(codepoint)
				val idx = Ints.indexOf(escapedCodePoints, codepoint)
				if (idx == -1) {
					builder.appendCodePoint(codepoint)
				} else {
					builder.appendCodePoint(escapeCodePoint)
					builder.appendCodePoint(escapedByCodePoints[idx])
				}
			}
			builder.toString()
		}
	}

	private fun firstOffsetNeedingUnescape(input: String): Int {
		val length = input.length
		var firstOffsetNeedingEscape = -1
		var offset = 0
		while (offset < length) {
			val codepoint = input.codePointAt(offset)
			if (codepoint == escapeCodePoint) {
				firstOffsetNeedingEscape = offset
				break
			}
			offset += Character.charCount(codepoint)
		}
		return firstOffsetNeedingEscape
	}

	override fun doBackward(input: String): String {
		val noEscapes = firstOffsetNeedingUnescape(input)
		return if (noEscapes == -1) {
			input
		} else {
			val length = input.length
			val needsEscapes = length - noEscapes
			val builder = StringBuilder(noEscapes + 4 + needsEscapes * 5 / 4)
			builder.append(input, 0, noEscapes)
			var offset = noEscapes
			while (offset < length) {
				var codepoint = input.codePointAt(offset)
				offset += Character.charCount(codepoint)
				// if we need to escape something, escape it
				if (codepoint == escapeCodePoint) {
					if (offset < length) {
						codepoint = input.codePointAt(offset)
						val idx = Ints.indexOf(escapedByCodePoints, codepoint)
						if (idx != -1) {
							codepoint = escapedCodePoints[idx]
						}
						offset += Character.charCount(codepoint)
					} else {
						throw IllegalArgumentException(
								"Escape character '" +
										String(intArrayOf(escapeCodePoint), 0, 1) +
										"' can't be the last character in a string.")
					}
				}
				// we didn't escape it, append it raw
				builder.appendCodePoint(codepoint)
			}
			builder.toString()
		}
	}

	companion object {
		/**
		 * If your escape policy is "'123", it means this:
		 *
		 * ```
		 * abc->abc
		 * 123->'1'2'3
		 * I won't->I won''t
		 * ```
		 */
		@JvmStatic
		fun selfEscape(escapePolicy: String): PerCharacterEscaper {
			val escapedCodePoints = escapePolicy.codePoints().toArray()
			val escapeCodePoint = escapedCodePoints[0]
			return PerCharacterEscaper(escapeCodePoint, escapedCodePoints, escapedCodePoints)
		}

		/**
		 * If your escape policy is "'a1b2c3d", it means this:
		 *
		 * ```
		 * abc->abc
		 * 123->'b'c'd
		 * I won't->I won'at
		 * ```
		 */
		@JvmStatic
		fun specifiedEscape(escapePolicy: String): PerCharacterEscaper {
			val codePoints = escapePolicy.codePoints().toArray()
			Preconditions.checkArgument(codePoints.size % 2 == 0)
			val escapeCodePoint = codePoints[0]
			val escapedCodePoints = IntArray(codePoints.size / 2)
			val escapedByCodePoints = IntArray(codePoints.size / 2)
			for (i in escapedCodePoints.indices) {
				escapedCodePoints[i] = codePoints[2 * i]
				escapedByCodePoints[i] = codePoints[2 * i + 1]
			}
			return PerCharacterEscaper(escapeCodePoint, escapedCodePoints, escapedByCodePoints)
		}
	}
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions