Skip to content

Commit

Permalink
Handle circular reference in snap function (#942)
Browse files Browse the repository at this point in the history
* handle circular references during reccursive child search

* add tests for circular reference snapshotting as dump

* fixes

---------

Co-authored-by: Oguz Yuksel <[email protected]>
Co-authored-by: Brandon Williams <[email protected]>
Co-authored-by: Brandon Williams <[email protected]>
  • Loading branch information
4 people authored Jan 14, 2025
1 parent d69b3df commit 41e5159
Show file tree
Hide file tree
Showing 5 changed files with 54 additions and 9 deletions.
2 changes: 2 additions & 0 deletions Sources/SnapshotTesting/AssertSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,8 @@ func sanitizePathComponent(_ string: String) -> String {
}

#if !os(Linux) && !os(Windows)
import CoreServices

func uniformTypeIdentifier(fromExtension pathExtension: String) -> String? {
// This can be much cleaner in macOS 11+ using UTType
let unmanagedString = UTTypeCreatePreferredIdentifierForTag(
Expand Down
31 changes: 23 additions & 8 deletions Sources/SnapshotTesting/Snapshotting/Any.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,23 +66,29 @@ extension Snapshotting where Format == String {
}
}

private func snap<T>(_ value: T, name: String? = nil, indent: Int = 0) -> String {
private func snap<T>(
_ value: T,
name: String? = nil,
indent: Int = 0,
visitedValues: Set<ObjectIdentifier> = .init()
) -> String {
let indentation = String(repeating: " ", count: indent)
let mirror = Mirror(reflecting: value)
var children = mirror.children
let count = children.count
let bullet = count == 0 ? "-" : ""
var visitedValues = visitedValues

let description: String
switch (value, mirror.displayStyle) {
case (_, .collection?):
description = count == 1 ? "1 element" : "\(count) elements"
case (_, .dictionary?):
description = count == 1 ? "1 key/value pair" : "\(count) key/value pairs"
children = sort(children)
children = sort(children, visitedValues: visitedValues)
case (_, .set?):
description = count == 1 ? "1 member" : "\(count) members"
children = sort(children)
children = sort(children, visitedValues: visitedValues)
case (_, .tuple?):
description = count == 1 ? "(1 element)" : "(\(count) elements)"
case (_, .optional?):
Expand All @@ -95,10 +101,19 @@ private func snap<T>(_ value: T, name: String? = nil, indent: Int = 0) -> String
return "\(indentation)- \(name.map { "\($0): " } ?? "")\(value.snapshotDescription)\n"
case (let value as CustomStringConvertible, _):
description = value.description
case (_, .class?), (_, .struct?):
case let (value as AnyObject, .class?):
let objectID = ObjectIdentifier(value)
if visitedValues.contains(objectID) {
return "\(indentation)\(bullet) \(name ?? "value") (circular reference detected)\n"
}
visitedValues.insert(objectID)
description = String(describing: mirror.subjectType)
.replacingOccurrences(of: " #\\d+", with: "", options: .regularExpression)
children = sort(children, visitedValues: visitedValues)
case (_, .struct?):
description = String(describing: mirror.subjectType)
.replacingOccurrences(of: " #\\d+", with: "", options: .regularExpression)
children = sort(children)
children = sort(children, visitedValues: visitedValues)
case (_, .enum?):
let subjectType = String(describing: mirror.subjectType)
.replacingOccurrences(of: " #\\d+", with: "", options: .regularExpression)
Expand All @@ -109,15 +124,15 @@ private func snap<T>(_ value: T, name: String? = nil, indent: Int = 0) -> String

let lines =
["\(indentation)\(bullet) \(name.map { "\($0): " } ?? "")\(description)\n"]
+ children.map { snap($1, name: $0, indent: indent + 2) }
+ children.map { snap($1, name: $0, indent: indent + 2, visitedValues: visitedValues) }

return lines.joined()
}

private func sort(_ children: Mirror.Children) -> Mirror.Children {
private func sort(_ children: Mirror.Children, visitedValues: Set<ObjectIdentifier>) -> Mirror.Children {
return .init(
children
.map({ (child: $0, snap: snap($0)) })
.map({ (child: $0, snap: snap($0, visitedValues: visitedValues)) })
.sorted(by: { $0.snap < $1.snap })
.map({ $0.child })
)
Expand Down
22 changes: 21 additions & 1 deletion Tests/SnapshotTestingTests/SnapshotTestingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import XCTest
import SwiftUI
#endif
#if canImport(WebKit)
import WebKit
@preconcurrency import WebKit
#endif
#if canImport(UIKit)
import UIKit.UIView
Expand All @@ -36,6 +36,26 @@ final class SnapshotTestingTests: XCTestCase {
assertSnapshot(of: user, as: .dump)
}

func testRecursion() {
withSnapshotTesting {
class Father {
var child: Child?
init(_ child: Child? = nil) { self.child = child }
}
class Child {
let father: Father
init(_ father: Father) {
self.father = father
father.child = self
}
}
let father = Father()
let child = Child(father)
assertSnapshot(of: father, as: .dump)
assertSnapshot(of: child, as: .dump)
}
}

@available(macOS 10.13, tvOS 11.0, *)
func testAnyAsJson() throws {
struct User: Encodable { let id: Int, name: String, bio: String }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
▿ Father
▿ child: Optional<Child>
▿ some: Child
▿ father (circular reference detected)
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
▿ Child
▿ father: Father
▿ child: Optional<Child>
▿ some (circular reference detected)

0 comments on commit 41e5159

Please sign in to comment.