diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 948e2e64e..719b259d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,10 +43,10 @@ jobs: strategy: matrix: swift: - - "5.8" + - "5.9.1" name: Windows (Swift ${{ matrix.swift }}) - runs-on: windows-2019 + runs-on: windows-latest steps: - uses: compnerd/gha-setup-swift@main @@ -59,6 +59,6 @@ jobs: git config --global core.autocrlf false git config --global core.eol lf - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - run: swift build - run: swift test diff --git a/Package.resolved b/Package.resolved index 48c07b6b5..58309571d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-syntax.git", "state" : { - "revision" : "74203046135342e4a4a627476dd6caf8b28fe11b", - "version" : "509.0.0" + "revision" : "08a2f0a9a30e0f705f79c9cfaca1f68b71bdc775", + "version" : "510.0.0" } } ], diff --git a/Package.swift b/Package.swift index 66244f237..d33189a35 100644 --- a/Package.swift +++ b/Package.swift @@ -21,7 +21,7 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://github.com/apple/swift-syntax.git", "508.0.1"..<"510.0.0") + .package(url: "https://github.com/apple/swift-syntax", "508.0.1"..<"511.0.0") ], targets: [ .target( diff --git a/README.md b/README.md index a18e31904..fae8506ac 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,9 @@ targets: [ - [SnapshotTestingHEIC](https://github.com/alexey1312/SnapshotTestingHEIC) adds image support using the HEIC storage format which reduces file sizes in comparison to PNG. + - [SnapshotVision](https://github.com/gregersson/swift-snapshot-testing-vision) adds snapshot + strategy for text recognition on views and images. Uses Apples Vision framework. + Have you written your own SnapshotTesting plug-in? [Add it here](https://github.com/pointfreeco/swift-snapshot-testing/edit/master/README.md) and submit a pull request! diff --git a/Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift b/Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift index ba77fae41..4d8bfabaa 100644 --- a/Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift +++ b/Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift @@ -32,7 +32,7 @@ import Foundation /// - column: The column where the assertion occurs. The default is the line column you call /// this function. public func assertInlineSnapshot( - of value: @autoclosure () throws -> Value, + of value: @autoclosure () throws -> Value?, as snapshotting: Snapshotting, message: @autoclosure () -> String = "", record isRecording: Bool = isRecording, @@ -46,41 +46,44 @@ import Foundation ) { let _: Void = installTestObserver do { - var actual: String! + var actual: String? let expectation = XCTestExpectation() - try snapshotting.snapshot(value()).run { - actual = $0 - expectation.fulfill() - } - switch XCTWaiter.wait(for: [expectation], timeout: timeout) { - case .completed: - break - case .timedOut: - XCTFail( - """ - Exceeded timeout of \(timeout) seconds waiting for snapshot. - - This can happen when an asynchronously loaded value (like a network response) has not \ - loaded. If a timeout is unavoidable, consider setting the "timeout" parameter of - "assertInlineSnapshot" to a higher value. - """, - file: file, - line: line - ) - return - case .incorrectOrder, .interrupted, .invertedFulfillment: - XCTFail("Couldn't snapshot value", file: file, line: line) - return - @unknown default: - XCTFail("Couldn't snapshot value", file: file, line: line) - return + if let value = try value() { + snapshotting.snapshot(value).run { + actual = $0 + expectation.fulfill() + } + switch XCTWaiter.wait(for: [expectation], timeout: timeout) { + case .completed: + break + case .timedOut: + XCTFail( + """ + Exceeded timeout of \(timeout) seconds waiting for snapshot. + + This can happen when an asynchronously loaded value (like a network response) has not \ + loaded. If a timeout is unavoidable, consider setting the "timeout" parameter of + "assertInlineSnapshot" to a higher value. + """, + file: file, + line: line + ) + return + case .incorrectOrder, .interrupted, .invertedFulfillment: + XCTFail("Couldn't snapshot value", file: file, line: line) + return + @unknown default: + XCTFail("Couldn't snapshot value", file: file, line: line) + return + } } - guard !isRecording, let expected = expected?() + let expected = expected?() + guard !isRecording, let expected else { // NB: Write snapshot state before calling `XCTFail` in case `continueAfterFailure = false` inlineSnapshotState[File(path: file), default: []].append( InlineSnapshot( - expected: expected?(), + expected: expected, actual: actual, wasRecording: isRecording, syntaxDescriptor: syntaxDescriptor, @@ -100,9 +103,7 @@ import Foundation Automatically recorded a new snapshot for "\(syntaxDescriptor.trailingClosureLabel)". """ } - if let expected = expected?(), - let difference = snapshotting.diffing.diff(expected, actual)?.0 - { + if let difference = snapshotting.diffing.diff(expected ?? "", actual ?? "")?.0 { failure += " Difference: …\n\n\(difference.indenting(by: 2))" } XCTFail( @@ -116,7 +117,7 @@ import Foundation ) return } - guard let difference = snapshotting.diffing.diff(expected, actual)?.0 + guard let difference = snapshotting.diffing.diff(expected, actual ?? "")?.0 else { return } let message = message() @@ -304,7 +305,7 @@ public struct InlineSnapshotSyntaxDescriptor: Hashable { private struct InlineSnapshot: Hashable { var expected: String? - var actual: String + var actual: String? var wasRecording: Bool var syntaxDescriptor: InlineSnapshotSyntaxDescriptor var function: String @@ -388,8 +389,8 @@ public struct InlineSnapshotSyntaxDescriptor: Hashable { self.wasRecording = snapshots.first?.wasRecording ?? isRecording self.indent = String( sourceLocationConverter.sourceLines - .first(where: { $0.first?.isWhitespace == true && $0 != "\n" })? - .prefix(while: { $0.isWhitespace }) + .first { $0.first?.isWhitespace == true && $0.contains { !$0.isWhitespace } }? + .prefix { $0.isWhitespace } ?? " " ) self.snapshots = snapshots @@ -421,40 +422,42 @@ public struct InlineSnapshotSyntaxDescriptor: Hashable { .prefix(while: { $0 == " " || $0 == "\t" }) ) let delimiter = String( - repeating: "#", count: snapshot.actual.hashCount(isMultiline: true) + repeating: "#", count: (snapshot.actual ?? "").hashCount(isMultiline: true) ) let leadingIndent = leadingTrivia + self.indent let snapshotLabel = TokenSyntax( stringLiteral: snapshot.syntaxDescriptor.trailingClosureLabel ) - let snapshotClosure = ClosureExprSyntax( - leftBrace: .leftBraceToken(trailingTrivia: .newline), - statements: CodeBlockItemListSyntax { - StringLiteralExprSyntax( - leadingTrivia: Trivia(stringLiteral: leadingIndent), - openingPounds: .rawStringPoundDelimiter(delimiter), - openingQuote: .multilineStringQuoteToken(trailingTrivia: .newline), - segments: [ - .stringSegment( - StringSegmentSyntax( - content: .stringSegment( - snapshot.actual - .replacingOccurrences(of: "\r", with: #"\\#(delimiter)r"#) - .indenting(with: leadingIndent) + let snapshotClosure = snapshot.actual.map { actual in + ClosureExprSyntax( + leftBrace: .leftBraceToken(trailingTrivia: .newline), + statements: CodeBlockItemListSyntax { + StringLiteralExprSyntax( + leadingTrivia: Trivia(stringLiteral: leadingIndent), + openingPounds: .rawStringPoundDelimiter(delimiter), + openingQuote: .multilineStringQuoteToken(trailingTrivia: .newline), + segments: [ + .stringSegment( + StringSegmentSyntax( + content: .stringSegment( + actual + .replacingOccurrences(of: "\r", with: #"\\#(delimiter)r"#) + .indenting(with: leadingIndent) + ) ) ) - ) - ], - closingQuote: .multilineStringQuoteToken( - leadingTrivia: .newline + Trivia(stringLiteral: leadingIndent) - ), - closingPounds: .rawStringPoundDelimiter(delimiter) + ], + closingQuote: .multilineStringQuoteToken( + leadingTrivia: .newline + Trivia(stringLiteral: leadingIndent) + ), + closingPounds: .rawStringPoundDelimiter(delimiter) + ) + }, + rightBrace: .rightBraceToken( + leadingTrivia: .newline + Trivia(stringLiteral: leadingTrivia) ) - }, - rightBrace: .rightBraceToken( - leadingTrivia: .newline + Trivia(stringLiteral: leadingTrivia) ) - ) + } let arguments = functionCallExpr.arguments let firstTrailingClosureOffset = @@ -475,23 +478,41 @@ public struct InlineSnapshotSyntaxDescriptor: Hashable { switch centeredTrailingClosureOffset { case ..<0: let index = arguments.index(arguments.startIndex, offsetBy: trailingClosureOffset) - functionCallExpr.arguments[index].label = snapshotLabel - functionCallExpr.arguments[index].expression = ExprSyntax(snapshotClosure) + if let snapshotClosure { + functionCallExpr.arguments[index].label = snapshotLabel + functionCallExpr.arguments[index].expression = ExprSyntax(snapshotClosure) + } else { + functionCallExpr.arguments.remove(at: index) + } case 0: if snapshot.wasRecording || functionCallExpr.trailingClosure == nil { functionCallExpr.rightParen?.trailingTrivia = .space - functionCallExpr.trailingClosure = snapshotClosure + if let snapshotClosure { + functionCallExpr.trailingClosure = snapshotClosure // FIXME: ?? multipleTrailingClosures.removeFirst() + } else if !functionCallExpr.additionalTrailingClosures.isEmpty { + let additionalTrailingClosure = functionCallExpr.additionalTrailingClosures.remove( + at: functionCallExpr.additionalTrailingClosures.startIndex + ) + functionCallExpr.trailingClosure = additionalTrailingClosure.closure + } else { + functionCallExpr.rightParen?.trailingTrivia = "" + functionCallExpr.trailingClosure = nil + } } else { fatalError() } case 1...: - var newElement: MultipleTrailingClosureElementSyntax { - MultipleTrailingClosureElementSyntax( - label: snapshotLabel, - closure: snapshotClosure.with(\.leadingTrivia, snapshotClosure.leadingTrivia + .space) - ) + var newElement: MultipleTrailingClosureElementSyntax? { + snapshotClosure.map { snapshotClosure in + MultipleTrailingClosureElementSyntax( + label: snapshotLabel, + closure: snapshotClosure.with( + \.leadingTrivia, snapshotClosure.leadingTrivia + .space + ) + ) + } } if !functionCallExpr.additionalTrailingClosures.isEmpty, @@ -510,16 +531,22 @@ public struct InlineSnapshotSyntaxDescriptor: Hashable { functionCallExpr.additionalTrailingClosures[index].label.text ) { if snapshot.wasRecording { - functionCallExpr.additionalTrailingClosures[index].label = snapshotLabel - functionCallExpr.additionalTrailingClosures[index].closure = snapshotClosure + if let snapshotClosure { + functionCallExpr.additionalTrailingClosures[index].label = snapshotLabel + functionCallExpr.additionalTrailingClosures[index].closure = snapshotClosure + } else { + functionCallExpr.additionalTrailingClosures.remove(at: index) + } } - } else { + } else if let newElement, + snapshot.wasRecording || index == functionCallExpr.additionalTrailingClosures.endIndex + { functionCallExpr.additionalTrailingClosures.insert( newElement.with(\.trailingTrivia, .space), at: index ) } - } else if centeredTrailingClosureOffset >= 1 { + } else if centeredTrailingClosureOffset >= 1, let newElement { if let index = functionCallExpr.additionalTrailingClosures.index( functionCallExpr.additionalTrailingClosures.endIndex, offsetBy: -1, diff --git a/Sources/SnapshotTesting/AssertSnapshot.swift b/Sources/SnapshotTesting/AssertSnapshot.swift index 552077e49..9d4a24318 100644 --- a/Sources/SnapshotTesting/AssertSnapshot.swift +++ b/Sources/SnapshotTesting/AssertSnapshot.swift @@ -371,18 +371,24 @@ func sanitizePathComponent(_ string: String) -> String { // We need to clean counter between tests executions in order to support test-iterations. private class CleanCounterBetweenTestCases: NSObject, XCTestObservation { private static var registered = false - private static var registerQueue = DispatchQueue( - label: "co.pointfree.SnapshotTesting.testObserver") static func registerIfNeeded() { - registerQueue.sync { - if !registered { - registered = true - XCTestObservationCenter.shared.addTestObserver(CleanCounterBetweenTestCases()) + if Thread.isMainThread { + doRegisterIfNeeded() + } else { + DispatchQueue.main.sync { + doRegisterIfNeeded() } } } + private static func doRegisterIfNeeded() { + if !registered { + registered = true + XCTestObservationCenter.shared.addTestObserver(CleanCounterBetweenTestCases()) + } + } + func testCaseDidFinish(_ testCase: XCTestCase) { counterQueue.sync { counterMap = [:] diff --git a/Sources/SnapshotTesting/Common/View.swift b/Sources/SnapshotTesting/Common/View.swift index c7dbba9d3..1bee81236 100644 --- a/Sources/SnapshotTesting/Common/View.swift +++ b/Sources/SnapshotTesting/Common/View.swift @@ -12,7 +12,7 @@ #endif #if os(iOS) || os(tvOS) - public struct ViewImageConfig { + public struct ViewImageConfig: Sendable { public enum Orientation { case landscape case portrait @@ -868,7 +868,11 @@ callback(Image()) return } - wkWebView.takeSnapshot(with: nil) { image, _ in + let configuration = WKSnapshotConfiguration() + if #available(iOS 13, macOS 10.15, *) { + configuration.afterScreenUpdates = false + } + wkWebView.takeSnapshot(with: configuration) { image, _ in callback(image!) } } diff --git a/Sources/SnapshotTesting/Snapshotting/UIImage.swift b/Sources/SnapshotTesting/Snapshotting/UIImage.swift index e5fc4262e..3d1bb5319 100644 --- a/Sources/SnapshotTesting/Snapshotting/UIImage.swift +++ b/Sources/SnapshotTesting/Snapshotting/UIImage.swift @@ -192,6 +192,7 @@ #endif #if os(iOS) || os(tvOS) || os(macOS) + import Accelerate.vImage import CoreImage.CIKernel import MetalPerformanceShaders @@ -199,50 +200,130 @@ func perceptuallyCompare( _ old: CIImage, _ new: CIImage, pixelPrecision: Float, perceptualPrecision: Float ) -> String? { - let deltaOutputImage = old.applyingFilter("CILabDeltaE", parameters: ["inputImage2": new]) - let thresholdOutputImage: CIImage - do { - thresholdOutputImage = try ThresholdImageProcessorKernel.apply( - withExtent: new.extent, - inputs: [deltaOutputImage], - arguments: [ - ThresholdImageProcessorKernel.inputThresholdKey: (1 - perceptualPrecision) * 100 - ] - ) - } catch { - return "Newly-taken snapshot's data could not be loaded. \(error)" - } - var averagePixel: Float = 0 + // Calculate the deltaE values. Each pixel is a value between 0-100. + // 0 means no difference, 100 means completely opposite. + let deltaOutputImage = old.applyingLabDeltaE(new) + // Setting the working color space and output color space to NSNull disables color management. This is appropriate when the output + // of the operations is computational instead of an image intended to be displayed. let context = CIContext(options: [.workingColorSpace: NSNull(), .outputColorSpace: NSNull()]) - context.render( - thresholdOutputImage.applyingFilter( - "CIAreaAverage", parameters: [kCIInputExtentKey: new.extent]), - toBitmap: &averagePixel, - rowBytes: MemoryLayout.size, - bounds: CGRect(x: 0, y: 0, width: 1, height: 1), - format: .Rf, - colorSpace: nil - ) - let actualPixelPrecision = 1 - averagePixel - guard actualPixelPrecision < pixelPrecision else { return nil } + let deltaThreshold = (1 - perceptualPrecision) * 100 + let actualPixelPrecision: Float var maximumDeltaE: Float = 0 - context.render( - deltaOutputImage.applyingFilter("CIAreaMaximum", parameters: [kCIInputExtentKey: new.extent]), - toBitmap: &maximumDeltaE, - rowBytes: MemoryLayout.size, - bounds: CGRect(x: 0, y: 0, width: 1, height: 1), - format: .Rf, - colorSpace: nil - ) - let actualPerceptualPrecision = 1 - maximumDeltaE / 100 - if pixelPrecision < 1 { - return """ - Actual image precision \(actualPixelPrecision) is less than required \(pixelPrecision) - Actual perceptual precision \(actualPerceptualPrecision) is less than required \(perceptualPrecision) - """ + + // Metal is supported by all iOS/tvOS devices (2013 models or later) and Macs (2012 models or later). + // Older devices do not support iOS/tvOS 13 and macOS 10.15 which are the minimum versions of swift-snapshot-testing. + // However, some virtualized hardware do not have GPUs and therefore do not support Metal. + // In this case, macOS falls back to a CPU-based OpenGL ES renderer that silently fails when a Metal command is issued. + // We need to check for Metal device support and fallback to CPU based vImage buffer iteration. + if ThresholdImageProcessorKernel.isSupported { + // Fast path - Metal processing + guard + let thresholdOutputImage = try? deltaOutputImage.applyingThreshold(deltaThreshold), + let averagePixel = thresholdOutputImage.applyingAreaAverage().renderSingleValue(in: context) + else { + return "Newly-taken snapshot's data could not be processed." + } + actualPixelPrecision = 1 - averagePixel + if actualPixelPrecision < pixelPrecision { + maximumDeltaE = deltaOutputImage.applyingAreaMaximum().renderSingleValue(in: context) ?? 0 + } } else { - return - "Actual perceptual precision \(actualPerceptualPrecision) is less than required \(perceptualPrecision)" + // Slow path - CPU based vImage buffer iteration + guard let buffer = deltaOutputImage.render(in: context) else { + return "Newly-taken snapshot could not be processed." + } + defer { buffer.free() } + var failingPixelCount: Int = 0 + // rowBytes must be a multiple of 8, so vImage_Buffer pads the end of each row with bytes to meet the multiple of 0 requirement. + // We must do 2D iteration of the vImage_Buffer in order to avoid loading the padding garbage bytes at the end of each row. + // + // NB: We are purposely using a verbose 'while' loop instead of a 'for in' loop. When the + // compiler doesn't have optimizations enabled, like in test targets, a `while` loop is + // significantly faster than a `for` loop for iterating through the elements of a memory + // buffer. Details can be found in [SR-6983](https://github.com/apple/swift/issues/49531) + let componentStride = MemoryLayout.stride + var line = 0 + while line < buffer.height { + defer { line += 1 } + let lineOffset = buffer.rowBytes * line + var column = 0 + while column < buffer.width { + defer { column += 1 } + let byteOffset = lineOffset + column * componentStride + let deltaE = buffer.data.load(fromByteOffset: byteOffset, as: Float.self) + if deltaE > deltaThreshold { + failingPixelCount += 1 + if deltaE > maximumDeltaE { + maximumDeltaE = deltaE + } + } + } + } + let failingPixelPercent = + Float(failingPixelCount) + / Float(deltaOutputImage.extent.width * deltaOutputImage.extent.height) + actualPixelPrecision = 1 - failingPixelPercent + } + + guard actualPixelPrecision < pixelPrecision else { return nil } + // The actual perceptual precision is the perceptual precision of the pixel with the highest DeltaE. + // DeltaE is in a 0-100 scale, so we need to divide by 100 to transform it to a percentage. + let minimumPerceptualPrecision = 1 - min(maximumDeltaE / 100, 1) + return """ + The percentage of pixels that match \(actualPixelPrecision) is less than required \(pixelPrecision) + The lowest perceptual color precision \(minimumPerceptualPrecision) is less than required \(perceptualPrecision) + """ + } + + extension CIImage { + func applyingLabDeltaE(_ other: CIImage) -> CIImage { + applyingFilter("CILabDeltaE", parameters: ["inputImage2": other]) + } + + func applyingThreshold(_ threshold: Float) throws -> CIImage { + try ThresholdImageProcessorKernel.apply( + withExtent: extent, + inputs: [self], + arguments: [ThresholdImageProcessorKernel.inputThresholdKey: threshold] + ) + } + + func applyingAreaAverage() -> CIImage { + applyingFilter("CIAreaAverage", parameters: [kCIInputExtentKey: extent]) + } + + func applyingAreaMaximum() -> CIImage { + applyingFilter("CIAreaMaximum", parameters: [kCIInputExtentKey: extent]) + } + + func renderSingleValue(in context: CIContext) -> Float? { + guard let buffer = render(in: context) else { return nil } + defer { buffer.free() } + return buffer.data.load(fromByteOffset: 0, as: Float.self) + } + + func render(in context: CIContext, format: CIFormat = CIFormat.Rh) -> vImage_Buffer? { + // Some hardware configurations (virtualized CPU renderers) do not support 32-bit float output formats, + // so use a compatible 16-bit float format and convert the output value to 32-bit floats. + guard + var buffer16 = try? vImage_Buffer( + width: Int(extent.width), height: Int(extent.height), bitsPerPixel: 16) + else { return nil } + defer { buffer16.free() } + context.render( + self, + toBitmap: buffer16.data, + rowBytes: buffer16.rowBytes, + bounds: extent, + format: format, + colorSpace: nil + ) + guard + var buffer32 = try? vImage_Buffer( + width: Int(buffer16.width), height: Int(buffer16.height), bitsPerPixel: 32), + vImageConvert_Planar16FtoPlanarF(&buffer16, &buffer32, 0) == kvImageNoError + else { return nil } + return buffer32 } } @@ -252,6 +333,19 @@ static let inputThresholdKey = "thresholdValue" static let device = MTLCreateSystemDefaultDevice() + static var isSupported: Bool { + guard let device = device else { + return false + } + #if targetEnvironment(simulator) + guard #available(iOS 14.0, tvOS 14.0, *) else { + // The MPSSupportsMTLDevice method throws an exception on iOS/tvOS simulators < 14.0 + return false + } + #endif + return MPSSupportsMTLDevice(device) + } + override class func process( with inputs: [CIImageProcessorInput]?, arguments: [String: Any]?, output: CIImageProcessorOutput diff --git a/Sources/SnapshotTesting/Snapshotting/URLRequest.swift b/Sources/SnapshotTesting/Snapshotting/URLRequest.swift index 596a8d4d9..c2699405f 100644 --- a/Sources/SnapshotTesting/Snapshotting/URLRequest.swift +++ b/Sources/SnapshotTesting/Snapshotting/URLRequest.swift @@ -1,128 +1,130 @@ -import Foundation - -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -extension Snapshotting where Value == URLRequest, Format == String { - /// A snapshot strategy for comparing requests based on raw equality. - /// - /// ``` swift - /// assertSnapshot(of: request, as: .raw) - /// ``` - /// - /// Records: - /// - /// ``` - /// POST http://localhost:8080/account - /// Cookie: pf_session={"userId":"1"} - /// - /// email=blob%40pointfree.co&name=Blob - /// ``` - public static let raw = Snapshotting.raw(pretty: false) - - /// A snapshot strategy for comparing requests based on raw equality. - /// - /// - Parameter pretty: Attempts to pretty print the body of the request (supports JSON). - public static func raw(pretty: Bool) -> Snapshotting { - return SimplySnapshotting.lines.pullback { (request: URLRequest) in - let method = - "\(request.httpMethod ?? "GET") \(request.url?.sortingQueryItems()?.absoluteString ?? "(null)")" - - let headers = (request.allHTTPHeaderFields ?? [:]) - .map { key, value in "\(key): \(value)" } - .sorted() - - let body: [String] - do { - if pretty, #available(iOS 11.0, macOS 10.13, tvOS 11.0, watchOS 4.0, *) { +#if !os(WASI) + import Foundation + + #if canImport(FoundationNetworking) + import FoundationNetworking + #endif + + extension Snapshotting where Value == URLRequest, Format == String { + /// A snapshot strategy for comparing requests based on raw equality. + /// + /// ``` swift + /// assertSnapshot(of: request, as: .raw) + /// ``` + /// + /// Records: + /// + /// ``` + /// POST http://localhost:8080/account + /// Cookie: pf_session={"userId":"1"} + /// + /// email=blob%40pointfree.co&name=Blob + /// ``` + public static let raw = Snapshotting.raw(pretty: false) + + /// A snapshot strategy for comparing requests based on raw equality. + /// + /// - Parameter pretty: Attempts to pretty print the body of the request (supports JSON). + public static func raw(pretty: Bool) -> Snapshotting { + return SimplySnapshotting.lines.pullback { (request: URLRequest) in + let method = + "\(request.httpMethod ?? "GET") \(request.url?.sortingQueryItems()?.absoluteString ?? "(null)")" + + let headers = (request.allHTTPHeaderFields ?? [:]) + .map { key, value in "\(key): \(value)" } + .sorted() + + let body: [String] + do { + if pretty, #available(iOS 11.0, macOS 10.13, tvOS 11.0, watchOS 4.0, *) { + body = + try request.httpBody + .map { try JSONSerialization.jsonObject(with: $0, options: []) } + .map { + try JSONSerialization.data( + withJSONObject: $0, options: [.prettyPrinted, .sortedKeys]) + } + .map { ["\n\(String(decoding: $0, as: UTF8.self))"] } + ?? [] + } else { + throw NSError(domain: "co.pointfree.Never", code: 1, userInfo: nil) + } + } catch { body = - try request.httpBody - .map { try JSONSerialization.jsonObject(with: $0, options: []) } - .map { - try JSONSerialization.data( - withJSONObject: $0, options: [.prettyPrinted, .sortedKeys]) - } + request.httpBody .map { ["\n\(String(decoding: $0, as: UTF8.self))"] } ?? [] - } else { - throw NSError(domain: "co.pointfree.Never", code: 1, userInfo: nil) } - } catch { - body = - request.httpBody - .map { ["\n\(String(decoding: $0, as: UTF8.self))"] } - ?? [] - } - return ([method] + headers + body).joined(separator: "\n") + return ([method] + headers + body).joined(separator: "\n") + } } - } - /// A snapshot strategy for comparing requests based on a cURL representation. - /// - // ``` swift - // assertSnapshot(of: request, as: .curl) - // ``` - // - // Records: - // - // ``` - // curl \ - // --request POST \ - // --header "Accept: text/html" \ - // --data 'pricing[billing]=monthly&pricing[lane]=individual' \ - // "https://www.pointfree.co/subscribe" - // ``` - public static let curl = SimplySnapshotting.lines.pullback { (request: URLRequest) in - - var components = ["curl"] - - // HTTP Method - let httpMethod = request.httpMethod! - switch httpMethod { - case "GET": break - case "HEAD": components.append("--head") - default: components.append("--request \(httpMethod)") - } + /// A snapshot strategy for comparing requests based on a cURL representation. + /// + // ``` swift + // assertSnapshot(of: request, as: .curl) + // ``` + // + // Records: + // + // ``` + // curl \ + // --request POST \ + // --header "Accept: text/html" \ + // --data 'pricing[billing]=monthly&pricing[lane]=individual' \ + // "https://www.pointfree.co/subscribe" + // ``` + public static let curl = SimplySnapshotting.lines.pullback { (request: URLRequest) in + + var components = ["curl"] + + // HTTP Method + let httpMethod = request.httpMethod! + switch httpMethod { + case "GET": break + case "HEAD": components.append("--head") + default: components.append("--request \(httpMethod)") + } - // Headers - if let headers = request.allHTTPHeaderFields { - for field in headers.keys.sorted() where field != "Cookie" { - let escapedValue = headers[field]!.replacingOccurrences(of: "\"", with: "\\\"") - components.append("--header \"\(field): \(escapedValue)\"") + // Headers + if let headers = request.allHTTPHeaderFields { + for field in headers.keys.sorted() where field != "Cookie" { + let escapedValue = headers[field]!.replacingOccurrences(of: "\"", with: "\\\"") + components.append("--header \"\(field): \(escapedValue)\"") + } } - } - // Body - if let httpBodyData = request.httpBody, - let httpBody = String(data: httpBodyData, encoding: .utf8) - { - var escapedBody = httpBody.replacingOccurrences(of: "\\\"", with: "\\\\\"") - escapedBody = escapedBody.replacingOccurrences(of: "\"", with: "\\\"") + // Body + if let httpBodyData = request.httpBody, + let httpBody = String(data: httpBodyData, encoding: .utf8) + { + var escapedBody = httpBody.replacingOccurrences(of: "\\\"", with: "\\\\\"") + escapedBody = escapedBody.replacingOccurrences(of: "\"", with: "\\\"") - components.append("--data \"\(escapedBody)\"") - } + components.append("--data \"\(escapedBody)\"") + } - // Cookies - if let cookie = request.allHTTPHeaderFields?["Cookie"] { - let escapedValue = cookie.replacingOccurrences(of: "\"", with: "\\\"") - components.append("--cookie \"\(escapedValue)\"") - } + // Cookies + if let cookie = request.allHTTPHeaderFields?["Cookie"] { + let escapedValue = cookie.replacingOccurrences(of: "\"", with: "\\\"") + components.append("--cookie \"\(escapedValue)\"") + } - // URL - components.append("\"\(request.url!.sortingQueryItems()!.absoluteString)\"") + // URL + components.append("\"\(request.url!.sortingQueryItems()!.absoluteString)\"") - return components.joined(separator: " \\\n\t") + return components.joined(separator: " \\\n\t") + } } -} -extension URL { - fileprivate func sortingQueryItems() -> URL? { - var components = URLComponents(url: self, resolvingAgainstBaseURL: false) - let sortedQueryItems = components?.queryItems?.sorted { $0.name < $1.name } - components?.queryItems = sortedQueryItems + extension URL { + fileprivate func sortingQueryItems() -> URL? { + var components = URLComponents(url: self, resolvingAgainstBaseURL: false) + let sortedQueryItems = components?.queryItems?.sorted { $0.name < $1.name } + components?.queryItems = sortedQueryItems - return components?.url + return components?.url + } } -} +#endif