diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bfc16f..7a1c6f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ concurrency: jobs: package: - name: Package build (macOS, ${{ matrix.traits.name }}) + name: Package tests (macOS, ${{ matrix.traits.name }}) runs-on: macos-15 strategy: matrix: @@ -20,16 +20,31 @@ jobs: - { name: "without DependencyInjection", flags: "--disable-default-traits" } steps: - uses: actions/checkout@v4 - - name: Build - run: swift build ${{ matrix.traits.flags }} + - name: Run tests + run: swift test ${{ matrix.traits.flags }} + + package-latest-xcode: + name: Package tests (macOS, latest Xcode) + # Warning severity uses Issue.record(severity:) from Swift 6.3; macos-15 only builds the fallback + runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + - name: Toolchain + run: swift --version + - name: Run tests + run: swift test package-ios: - name: Package build (iOS Simulator) + name: Package tests (iOS Simulator) runs-on: macos-15 steps: - uses: actions/checkout@v4 - - name: Build + - name: Run tests run: | - xcodebuild build \ + # Use whichever iPhone simulator the runner's Xcode provides + UDID=$(xcrun simctl list devices available -j \ + | jq -r '[.devices | to_entries[] | select(.key | contains("iOS")) | .value[] | select(.name | startswith("iPhone"))][0].udid') + echo "Simulator: $UDID" + xcodebuild test \ -scheme DeallocTests \ - -destination 'generic/platform=iOS Simulator' + -destination "id=$UDID" diff --git a/Package.swift b/Package.swift index 90619ab..8c8fd35 100644 --- a/Package.swift +++ b/Package.swift @@ -24,7 +24,7 @@ let package = Package( traits: [ .trait( name: "DependencyInjection", - description: "Integration with STRV Dependency Injection: the AsyncContainer in DeallocTester" + description: "Integration with STRV Dependency Injection: expectDeallocation(of:resolvedFrom:)" ), .default(enabledTraits: ["DependencyInjection"]), ], @@ -42,6 +42,17 @@ let package = Package( ) ] ), + .testTarget( + name: "DeallocTestsTests", + dependencies: [ + "DeallocTests", + .product( + name: "DependencyInjection", + package: "ios-dependency-injection", + condition: .when(traits: ["DependencyInjection"]) + ) + ] + ), ], swiftLanguageModes: [.v6] ) diff --git a/Sources/DeallocTests/Diagnostics/DurationText.swift b/Sources/DeallocTests/Diagnostics/DurationText.swift new file mode 100644 index 0000000..b70ca39 --- /dev/null +++ b/Sources/DeallocTests/Diagnostics/DurationText.swift @@ -0,0 +1,20 @@ +// +// DurationText.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +enum DurationText { + static func describe(_ duration: Duration) -> String { + let (seconds, attoseconds) = duration.components + let milliseconds = seconds * 1000 + attoseconds / 1_000_000_000_000_000 + + guard milliseconds >= 1000 else { + return "\(milliseconds) ms" + } + + let tenths = milliseconds / 100 + return tenths % 10 == 0 ? "\(tenths / 10) sec" : "\(tenths / 10).\(tenths % 10) sec" + } +} diff --git a/Sources/DeallocTests/Diagnostics/LeakHints.swift b/Sources/DeallocTests/Diagnostics/LeakHints.swift new file mode 100644 index 0000000..1edffae --- /dev/null +++ b/Sources/DeallocTests/Diagnostics/LeakHints.swift @@ -0,0 +1,165 @@ +// +// LeakHints.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import Foundation + +enum LeakHints { + static let maximumCycleDepth = 4 + static let maximumVisitedObjects = 300 + + static func hints(for object: AnyObject) -> [String] { + var hints = [String]() + + for property in storedProperties(of: object) { + if let kind = suspiciousKind(of: property.value) { + hints.append("`\(property.label)` is \(kind)") + } + } + + hints += cycles(from: object).map { path in + "`\(path)` refers back to the object. That's a retain cycle unless one of the references is weak" + } + + return hints + } + + // MARK: - Suspicious properties + + private static func suspiciousKind(of value: Any) -> String? { + guard let value = unwrapped(value), !isEmptyCollection(value) else { + return nil + } + + let typeName = String(describing: type(of: value)) + + if typeName.contains("->") { + return "a closure. Make sure it captures self weakly" + } + + if typeName.contains("AnyCancellable") { + return "a Combine subscription. Make sure its sink captures self weakly" + } + + if typeName.hasPrefix("Task<") { + return "a task. Make sure it's cancelled or captures self weakly" + } + + if value is Timer { + return "a timer. A scheduled timer keeps its target until it's invalidated" + } + + return nil + } + + private static func unwrapped(_ value: Any) -> Any? { + let mirror = Mirror(reflecting: value) + + guard mirror.displayStyle == .optional else { + return value + } + + return mirror.children.first.flatMap { unwrapped($0.value) } + } + + private static func isEmptyCollection(_ value: Any) -> Bool { + let mirror = Mirror(reflecting: value) + + switch mirror.displayStyle { + case .collection, .set, .dictionary: + return mirror.children.isEmpty + default: + return false + } + } + + // MARK: - Cycles + + private static func cycles(from root: AnyObject) -> [String] { + let rootIdentifier = ObjectIdentifier(root) + var visited: Set = [rootIdentifier] + var queue: [(object: AnyObject, path: String, depth: Int)] = [(root, "self", 0)] + var found = [String]() + + while !queue.isEmpty, visited.count < maximumVisitedObjects { + let (object, path, depth) = queue.removeFirst() + + guard depth < maximumCycleDepth else { + continue + } + + for property in storedProperties(of: object) { + for child in referencedObjects(in: property.value) { + let childPath = "\(path).\(property.label)" + let childIdentifier = ObjectIdentifier(child) + + if childIdentifier == rootIdentifier { + found.append(childPath) + } else if isUserDefined(type(of: child)), visited.insert(childIdentifier).inserted { + queue.append((child, childPath, depth + 1)) + } + } + } + } + + return found + } + + // MARK: - Reflection + + private struct Property { + let label: String + let value: Any + } + + private static func storedProperties(of object: AnyObject) -> [Property] { + var properties = [Property]() + var mirror: Mirror? = Mirror(reflecting: object) + + while let currentMirror = mirror { + if let subjectType = currentMirror.subjectType as? AnyClass, !isUserDefined(subjectType) { + break + } + + for child in currentMirror.children { + guard let label = child.label, !label.hasPrefix("_$") else { + continue + } + properties.append(Property(label: cleaned(label), value: child.value)) + } + + mirror = currentMirror.superclassMirror + } + + return properties + } + + private static func referencedObjects(in value: Any, depth: Int = 0) -> [AnyObject] { + let mirror = Mirror(reflecting: value) + + if mirror.displayStyle == .class { + return [value as AnyObject] + } + + guard depth < 3 else { + return [] + } + + return mirror.children.prefix(50).flatMap { referencedObjects(in: $0.value, depth: depth + 1) } + } + + private static func isUserDefined(_ objectClass: AnyClass) -> Bool { + guard let bundleIdentifier = Bundle(for: objectClass).bundleIdentifier else { + return true + } + return !bundleIdentifier.hasPrefix("com.apple.") + } + + private static func cleaned(_ label: String) -> String { + let label = label.replacingOccurrences(of: "$__lazy_storage_$_", with: "") + return label.hasPrefix("_") ? String(label.dropFirst()) : label + } +} diff --git a/Sources/DeallocTests/Diagnostics/LeakReport.swift b/Sources/DeallocTests/Diagnostics/LeakReport.swift new file mode 100644 index 0000000..f755f4e --- /dev/null +++ b/Sources/DeallocTests/Diagnostics/LeakReport.swift @@ -0,0 +1,41 @@ +// +// LeakReport.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +struct LeakReport: Sendable { + let typeName: String + let timeout: Duration + let gracePeriod: Duration + let hints: [String] + + init(typeName: String, timeout: Duration, gracePeriod: Duration = .zero, hints: [String] = []) { + self.typeName = typeName + self.timeout = timeout + self.gracePeriod = gracePeriod + self.hints = hints + } + + static func lateReleaseMessage(typeName: String, releasedAfter: Duration, timeout: Duration) -> String { + "\(typeName) was released after \(DurationText.describe(releasedAfter)), later than the \(DurationText.describe(timeout)) timeout. " + + "That's bounded retention, not a leak: something kept it alive for a while, e.g. a task, " + + "an animation or a delayed callback. If that's expected, raise the timeout." + } + + var message: String { + var summary = "\(typeName) was not deallocated within \(DurationText.describe(timeout))." + if gracePeriod > .zero { + summary += " It was watched for another \(DurationText.describe(gracePeriod)) after that." + } + + guard !hints.isEmpty else { + return summary + " Something still holds a strong reference to it: look for closures capturing self, " + + "delegates that aren't weak, timers, notification observers and long-running tasks or subscriptions." + } + + let causes = hints + ["Or something outside still holds it: a parent's list of children, a cache or a singleton"] + return summary + " Possible causes:\n" + causes.map { " • \($0)" }.joined(separator: "\n") + } +} diff --git a/Sources/DeallocTests/Diagnostics/TypeNames.swift b/Sources/DeallocTests/Diagnostics/TypeNames.swift new file mode 100644 index 0000000..fd621ee --- /dev/null +++ b/Sources/DeallocTests/Diagnostics/TypeNames.swift @@ -0,0 +1,19 @@ +// +// TypeNames.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import Foundation + +enum TypeNames { + static func readableName(of object: AnyObject) -> String { + readableName(of: type(of: object)) + } + + static func readableName(of type: Any.Type) -> String { + String(reflecting: type) + .replacingOccurrences(of: #"\(unknown context at \$[0-9a-fA-F]+\)\."#, with: "", options: .regularExpression) + } +} diff --git a/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift b/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift new file mode 100644 index 0000000..bc862fc --- /dev/null +++ b/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift @@ -0,0 +1,144 @@ +// +// DeallocationConfiguration.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +#if canImport(Testing) + import Testing +#endif + +/// How deallocation checks behave by default. +/// +/// An object is checked with the configuration in effect where it's tracked. Only the timeout +/// can also be passed to `expectDeallocation`, because it describes the object. Set the +/// configuration for a Swift Testing suite or test with traits such as `.deallocationTimeout(_:)`, +/// or for a block of code with `withDeallocationConfiguration(_:operation:)`. +public struct DeallocationConfiguration: Sendable { + /// How long a check waits for the objects to deallocate + public var timeout: Duration = .seconds(2) + /// How much longer to keep watching objects that are still alive after the timeout. + /// An object released in that time is reported as a warning (bounded retention, not a + /// leak) instead of a failure. `.zero` turns it off. Not used when `severity` is `.warning`, + /// because a leak is then reported as a warning anyway. + public var gracePeriod: Duration = .seconds(3) + /// Whether a leak fails the test or is reported as a warning + public var severity: DeallocationIssueSeverity = .error + + public init() {} + + /// The configuration that applies to the current task + @TaskLocal public static var current = DeallocationConfiguration() +} + +extension DeallocationConfiguration { + var effectiveGracePeriod: Duration { + severity == .error ? gracePeriod : .zero + } +} + +/// How a leak is reported +public enum DeallocationIssueSeverity: Sendable { + /// The test fails + case error + /// The test passes and the leak is shown as a warning, e.g. while adopting dealloc tests + case warning +} + +/// Runs the operation with a changed deallocation configuration. +/// +/// The way to configure checks in XCTest, which has no traits: +/// +/// ```swift +/// func test_profileScreen() async { +/// await withDeallocationConfiguration({ $0.timeout = .seconds(5) }) { +/// await expectDeallocation(.present) { makeProfileViewController() } +/// } +/// } +/// ``` +/// +/// It runs on the caller's actor, so it can be called from `@MainActor` tests. +public func withDeallocationConfiguration( + _ change: (inout DeallocationConfiguration) -> Void, + isolation: isolated (any Actor)? = #isolation, + operation: () async throws -> Result +) async rethrows -> Result { + var configuration = DeallocationConfiguration.current + change(&configuration) + return try await DeallocationConfiguration.$current.withValue(configuration) { + try await operation() + } +} + +/// Runs the operation with a changed deallocation configuration. +/// +/// The synchronous variant, e.g. for a whole XCTest test case class: +/// +/// ```swift +/// final class LegacyDeallocTests: XCTestCase { +/// override func invokeTest() { +/// withDeallocationConfiguration({ $0.severity = .warning }) { +/// super.invokeTest() +/// } +/// } +/// } +/// ``` +public func withDeallocationConfiguration( + _ change: (inout DeallocationConfiguration) -> Void, + operation: () throws -> Result +) rethrows -> Result { + var configuration = DeallocationConfiguration.current + change(&configuration) + return try DeallocationConfiguration.$current.withValue(configuration) { + try operation() + } +} + +#if canImport(Testing) + +/// Changes the deallocation configuration for a test, or for every test in a suite. +/// A test's own trait wins over its suite's. +public struct DeallocationConfigurationTrait: TestTrait, SuiteTrait, TestScoping { + let change: @Sendable (inout DeallocationConfiguration) -> Void + + public var isRecursive: Bool { + true + } + + public func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: @Sendable () async throws -> Void + ) async throws { + var configuration = DeallocationConfiguration.current + change(&configuration) + try await DeallocationConfiguration.$current.withValue(configuration) { + try await function() + } + } +} + +public extension Trait where Self == DeallocationConfigurationTrait { + /// How long deallocation checks wait for objects to deallocate + /// + /// ```swift + /// @Suite(.deallocationTimeout(.seconds(5))) + /// struct ScreenDeallocTests { … } + /// ``` + static func deallocationTimeout(_ timeout: Duration) -> Self { + Self { $0.timeout = timeout } + } + + /// How much longer to watch objects still alive after the timeout before calling them leaked + static func deallocationGracePeriod(_ gracePeriod: Duration) -> Self { + Self { $0.gracePeriod = gracePeriod } + } + + /// Whether leaks fail the test (`.error`, the default) or are reported as warnings + static func deallocationIssues(_ severity: DeallocationIssueSeverity) -> Self { + Self { $0.severity = severity } + } +} + +#endif diff --git a/Sources/DeallocTests/Expectation/DeallocationTracker.swift b/Sources/DeallocTests/Expectation/DeallocationTracker.swift new file mode 100644 index 0000000..56ffece --- /dev/null +++ b/Sources/DeallocTests/Expectation/DeallocationTracker.swift @@ -0,0 +1,113 @@ +// +// DeallocationTracker.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + + +@MainActor +final class DeallocationTracker { + private struct TrackedObject { + weak var object: AnyObject? + let typeName: String + let location: TestSourceLocation + let configuration: DeallocationConfiguration + } + + private struct Check { + let trackedObject: TrackedObject + let timeout: Duration + let gracePeriod: Duration + } + + @TaskLocal static var current: DeallocationTracker? + + private var trackedObjects = [TrackedObject]() + + func track(_ object: AnyObject, at location: TestSourceLocation) { + trackedObjects.append( + TrackedObject( + object: object, + typeName: TypeNames.readableName(of: object), + location: location, + configuration: DeallocationConfiguration.current + ) + ) + } + + func verifyDeallocation(timeout: Duration? = nil) async { + let checks = trackedObjects.map { trackedObject in + Check( + trackedObject: trackedObject, + timeout: timeout ?? trackedObject.configuration.timeout, + gracePeriod: trackedObject.configuration.effectiveGracePeriod + ) + } + trackedObjects.removeAll() + + let clock = ContinuousClock() + let start = clock.now + let longestWait = checks.map { $0.timeout + $0.gracePeriod }.max() ?? .zero + var pending = checks + var lateReleases = [(Check, Duration)]() + var leaks = [Check]() + + _ = await Polling.waitUntil(timeout: longestWait) { + let elapsed = clock.now - start + + pending.removeAll { check in + if check.trackedObject.object == nil { + if elapsed > check.timeout, check.gracePeriod > .zero { + lateReleases.append((check, elapsed)) + } + return true + } + + if elapsed >= check.timeout + check.gracePeriod { + leaks.append(check) + return true + } + + return false + } + + return pending.isEmpty + } + + leaks += pending + + guard !Task.isCancelled else { + return + } + + for (check, releasedAfter) in lateReleases { + reportIssue( + LeakReport.lateReleaseMessage( + typeName: check.trackedObject.typeName, + releasedAfter: releasedAfter, + timeout: check.timeout + ), + at: check.trackedObject.location, + severity: .warning + ) + } + + for check in leaks { + guard let object = check.trackedObject.object else { + continue + } + + reportIssue( + LeakReport( + typeName: check.trackedObject.typeName, + timeout: check.timeout, + gracePeriod: check.gracePeriod, + hints: LeakHints.hints(for: object) + ).message, + at: check.trackedObject.location, + severity: check.trackedObject.configuration.severity + ) + } + } +} diff --git a/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift b/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift new file mode 100644 index 0000000..1698d5c --- /dev/null +++ b/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift @@ -0,0 +1,71 @@ +// +// ExpectDeallocation+DependencyInjection.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +#if DependencyInjection + +import DependencyInjection + +/// Resolves a dependency, releases it together with the container's shared instances +/// and checks that it deallocates. +/// +/// ```swift +/// @Test func apiManagerDoesNotLeak() async { +/// let container = AsyncContainer() +/// await container.register(type: APIManaging.self, in: .shared) { _ in APIManager() } +/// +/// await expectDeallocation(of: APIManaging.self, resolvedFrom: container) +/// } +/// ``` +/// +/// - Parameters: +/// - type: Registered type to resolve. The resolved instance must be a class instance. +/// - container: Container with the registration +/// - timeout: How long to wait for the object to deallocate. Defaults to `DeallocationConfiguration.current.timeout` +@MainActor +public func expectDeallocation( + of type: Dependency.Type, + resolvedFrom container: AsyncContainer, + timeout: Duration? = nil, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column +) async { + let location = TestSourceLocation(fileID: fileID, filePath: filePath, line: line, column: column) + let tracker = DeallocationTracker() + + guard await resolveAndTrack(type, from: container, tracker: tracker, location: location) else { + return + } + + await container.releaseSharedInstances() + await tracker.verifyDeallocation(timeout: timeout) +} + +@MainActor +private func resolveAndTrack( + _ type: Dependency.Type, + from container: AsyncContainer, + tracker: DeallocationTracker, + location: TestSourceLocation +) async -> Bool { + let dependency = await container.resolve(type: type) + + guard Mirror(reflecting: dependency).displayStyle == .class else { + let concreteType = Swift.type(of: dependency as Any) + reportIssue( + "\(concreteType) resolved for \(type) is a value type, so it can't leak. Check the class instances it holds instead.", + at: location + ) + return false + } + + tracker.track(dependency as AnyObject, at: location) + return true +} + +#endif diff --git a/Sources/DeallocTests/Expectation/ExpectDeallocation.swift b/Sources/DeallocTests/Expectation/ExpectDeallocation.swift new file mode 100644 index 0000000..ca78a62 --- /dev/null +++ b/Sources/DeallocTests/Expectation/ExpectDeallocation.swift @@ -0,0 +1,63 @@ +// +// ExpectDeallocation.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +/// Creates an object, runs its lifecycle, releases it and checks that it deallocates. +/// +/// Works in Swift Testing and XCTest. A leak is reported at the line that calls this function, +/// with hints about properties that commonly cause leaks. +/// +/// ```swift +/// @Test func secondScreenDoesNotLeak() async { +/// await expectDeallocation(.present) { +/// coordinator.createSecondViewController() +/// } +/// } +/// ``` +/// +/// - Parameters: +/// - lifecycle: What happens with the object before it's released, e.g. `.present` for a view controller +/// - timeout: How long to wait for the object to deallocate. Defaults to `DeallocationConfiguration.current.timeout` +/// - afterRelease: Runs after the object is released and before the check, e.g. to release cached instances +/// - makeObject: Creates the tested object. Don't keep any other reference to it. +/// Objects passed to `trackForDeallocation(_:)` inside it are checked too. +@MainActor +public func expectDeallocation( + _ lifecycle: Lifecycle = .none, + timeout: Duration? = nil, + afterRelease: @MainActor () async -> Void = {}, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column, + of makeObject: @MainActor () async throws -> Object +) async rethrows { + let location = TestSourceLocation(fileID: fileID, filePath: filePath, line: line, column: column) + let tracker = DeallocationTracker() + + let didRunLifecycle = try await createAndRun(makeObject, lifecycle: lifecycle, tracker: tracker, location: location) + await afterRelease() + + guard didRunLifecycle else { + return + } + + await tracker.verifyDeallocation(timeout: timeout) +} + +@MainActor +private func createAndRun( + _ makeObject: @MainActor () async throws -> Object, + lifecycle: Lifecycle, + tracker: DeallocationTracker, + location: TestSourceLocation +) async rethrows -> Bool { + try await DeallocationTracker.$current.withValue(tracker) { + let object = try await makeObject() + tracker.track(object, at: location) + return await lifecycle.run(object, location) + } +} diff --git a/Sources/DeallocTests/Expectation/IssueReporting.swift b/Sources/DeallocTests/Expectation/IssueReporting.swift new file mode 100644 index 0000000..1187005 --- /dev/null +++ b/Sources/DeallocTests/Expectation/IssueReporting.swift @@ -0,0 +1,69 @@ +// +// IssueReporting.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import XCTest + +#if canImport(Testing) + import Testing +#endif + +struct TestSourceLocation: Sendable { + let fileID: StaticString + let filePath: StaticString + let line: UInt + let column: UInt +} + +func reportIssue(_ message: String, at location: TestSourceLocation, severity: DeallocationIssueSeverity = .error) { + guard !Task.isCancelled else { + return + } + + #if canImport(Testing) + if Test.current != nil { + recordSwiftTestingIssue(message, at: location, severity: severity) + return + } + #endif + + switch severity { + case .error: + XCTFail(message, file: location.filePath, line: location.line) + case .warning: + let options = XCTExpectedFailure.Options() + options.isStrict = false + XCTExpectFailure("Reported as a warning", options: options) { + XCTFail(message, file: location.filePath, line: location.line) + } + } +} + +#if canImport(Testing) + +private func recordSwiftTestingIssue(_ message: String, at location: TestSourceLocation, severity: DeallocationIssueSeverity) { + let sourceLocation = SourceLocation( + fileID: String(describing: location.fileID), + filePath: String(describing: location.filePath), + line: Int(location.line), + column: Int(location.column) + ) + + switch severity { + case .error: + Issue.record(Comment(rawValue: message), sourceLocation: sourceLocation) + case .warning: + #if compiler(>=6.3) + Issue.record(Comment(rawValue: message), severity: .warning, sourceLocation: sourceLocation) + #else + withKnownIssue("Reported as a warning", isIntermittent: true) { + Issue.record(Comment(rawValue: message), sourceLocation: sourceLocation) + } + #endif + } +} + +#endif diff --git a/Sources/DeallocTests/Expectation/Lifecycle+SwiftUI.swift b/Sources/DeallocTests/Expectation/Lifecycle+SwiftUI.swift new file mode 100644 index 0000000..703c7b9 --- /dev/null +++ b/Sources/DeallocTests/Expectation/Lifecycle+SwiftUI.swift @@ -0,0 +1,140 @@ +// +// Lifecycle+SwiftUI.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +#if canImport(SwiftUI) + +import SwiftUI + +public extension Lifecycle { + /// Shows a SwiftUI view built from the object, typically its view model, in a test window, then removes it. + /// + /// `onAppear` and `.task` run while the view is shown. When it's removed, SwiftUI cancels its tasks. + /// + /// ```swift + /// await expectDeallocation(.hosting { ProfileView(viewModel: $0) }) { + /// ProfileViewModel() + /// } + /// ``` + /// + /// - Parameters: + /// - interaction: Runs while the view is on screen + /// - content: Builds the view from the object + static func hosting( + interaction: Interaction? = nil, + @ViewBuilder _ content: @escaping @MainActor (Object) -> Content + ) -> Self { + Self { object, location in + let presence = ViewPresence() + let rootView = PresenceReporting(content: content(object), presence: presence) + + guard let host = await SwiftUIHost(rootView: rootView, location: location) else { + return false + } + + guard await waitUntil({ presence.hasStartedTasks }) else { + reportIssue("The SwiftUI view did not appear", at: location) + host.remove() + return false + } + + await settle() + await perform(interaction, with: object, at: location) + + host.remove() + + await settle() + return true + } + } +} + +@MainActor +private final class ViewPresence { + var hasStartedTasks = false +} + +private struct PresenceReporting: View { + let content: Content + let presence: ViewPresence + + var body: some View { + content.task { presence.hasStartedTasks = true } + } +} + +@MainActor +private func settle() async { + try? await Task.sleep(for: .milliseconds(50)) +} + +#if canImport(UIKit) + +@MainActor +private final class SwiftUIHost { + private let window: TestWindow + private let hostController: HostViewController + private let hostingController: UIHostingController + + init?(rootView: Content, location: TestSourceLocation) async { + hostController = HostViewController() + window = TestWindow(rootViewController: hostController) + hostingController = UIHostingController(rootView: rootView) + + guard await window.waitUntilVisible(hostController, at: location) else { + window.close() + return nil + } + + hostController.addChild(hostingController) + hostingController.view.frame = hostController.view.bounds + hostController.view.addSubview(hostingController.view) + hostingController.didMove(toParent: hostController) + + guard await waitUntil({ [hostingController] in hostingController.viewIfLoaded?.window != nil }) else { + reportIssue("The SwiftUI view could not be shown", at: location) + remove() + return nil + } + } + + func remove() { + hostingController.willMove(toParent: nil) + hostingController.view.removeFromSuperview() + hostingController.removeFromParent() + window.close() + } +} + +#elseif canImport(AppKit) + +@MainActor +private final class SwiftUIHost { + private let window: NSWindow + + init?(rootView: Content, location: TestSourceLocation) async { + let hostingController = NSHostingController(rootView: rootView) + window = NSWindow(contentViewController: hostingController) + window.isReleasedWhenClosed = false + window.orderFront(nil) + + guard await waitUntil({ [window] in hostingController.view.window === window && window.isVisible }) else { + reportIssue("The SwiftUI view could not be shown", at: location) + remove() + return nil + } + } + + func remove() { + window.orderOut(nil) + window.contentViewController = nil + window.close() + } +} + +#endif + +#endif diff --git a/Sources/DeallocTests/Expectation/Lifecycle.swift b/Sources/DeallocTests/Expectation/Lifecycle.swift new file mode 100644 index 0000000..274f1cc --- /dev/null +++ b/Sources/DeallocTests/Expectation/Lifecycle.swift @@ -0,0 +1,239 @@ +// +// Lifecycle.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + + +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif + +/// What happens with the tested object between its creation and release. +/// +/// Many leaks only appear once a view controller loads its view or appears on screen, +/// so view controllers should usually be checked with `.present` or `.push`. +public struct Lifecycle: Sendable { + public typealias Interaction = @MainActor (Object) async throws -> Void + + let run: @MainActor (Object, TestSourceLocation) async -> Bool + + init(run: @escaping @MainActor (Object, TestSourceLocation) async -> Bool) { + self.run = run + } + + /// The object is released right after it's created + public static var none: Self { + Self { _, _ in true } + } + + /// Runs custom code with the object before it's released, e.g. calls the methods you suspect of leaking + public static func custom(_ body: @escaping Interaction) -> Self { + Self { object, location in + await perform(body, with: object, at: location) + return true + } + } +} + +extension Lifecycle { + @MainActor + static func perform(_ interaction: Interaction?, with object: Object, at location: TestSourceLocation) async { + guard let interaction else { + return + } + + do { + try await interaction(object) + } catch { + reportIssue("Lifecycle interaction with \(type(of: object)) threw an error: \(error)", at: location) + } + } +} + +@MainActor +func waitUntil(timeout: Duration = .seconds(10), _ condition: @MainActor () -> Bool) async -> Bool { + await Polling.waitUntil(timeout: timeout, interval: .milliseconds(5), condition) +} + +#if canImport(UIKit) + +// MARK: - UIKit + +public extension Lifecycle where Object: UIViewController { + /// Loads the view, so `viewDidLoad` runs + static var loadView: Self { + Self { controller, _ in + controller.loadViewIfNeeded() + return true + } + } + + /// Presents the controller modally in a test window, then dismisses it + static var present: Self { + present() + } + + /// Presents the controller modally in a test window, then dismisses it + /// - Parameters: + /// - style: Modal presentation style + /// - interaction: Runs while the controller is on screen + static func present(style: UIModalPresentationStyle = .fullScreen, interaction: Interaction? = nil) -> Self { + Self { controller, location in + let hostController = HostViewController() + let host = TestWindow(rootViewController: hostController) + defer { host.close() } + + guard await host.waitUntilVisible(hostController, at: location) else { + return false + } + + controller.modalPresentationStyle = style + if let popover = controller.popoverPresentationController { + let bounds = hostController.view.bounds + popover.sourceView = hostController.view + popover.sourceRect = CGRect(x: bounds.midX, y: bounds.midY, width: 0, height: 0) + } + hostController.present(controller, animated: false) + + guard await waitUntil({ controller.viewIfLoaded?.window != nil }) else { + let reason = host.hasWindowScene ? "" : ". Modal presentation needs a window scene, so the test target needs a host app." + reportIssue("\(type(of: controller)) could not be presented\(reason)", at: location) + return false + } + + await perform(interaction, with: controller, at: location) + + hostController.dismiss(animated: false) + + guard await waitUntil({ hostController.presentedViewController == nil }) else { + reportIssue("\(type(of: controller)) could not be dismissed", at: location) + return false + } + + return true + } + } + + /// Pushes the controller onto a navigation controller in a test window, then pops it + static var push: Self { + push() + } + + /// Pushes the controller onto a navigation controller in a test window, then pops it + /// - Parameter interaction: Runs while the controller is on screen + static func push(interaction: Interaction? = nil) -> Self { + Self { controller, location in + let hostController = HostViewController() + let navigationController = UINavigationController(rootViewController: hostController) + let host = TestWindow(rootViewController: navigationController) + defer { host.close() } + + guard await host.waitUntilVisible(hostController, at: location) else { + return false + } + + navigationController.pushViewController(controller, animated: false) + + guard await waitUntil({ controller.viewIfLoaded?.window != nil }) else { + reportIssue("\(type(of: controller)) could not be pushed", at: location) + return false + } + + await perform(interaction, with: controller, at: location) + + navigationController.popViewController(animated: false) + + guard await waitUntil({ controller.navigationController == nil }) else { + reportIssue("\(type(of: controller)) could not be popped", at: location) + return false + } + + return true + } + } +} + +@MainActor +private func foregroundWindowScene() -> UIWindowScene? { + guard let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication else { + return nil + } + + let windowScenes = application.connectedScenes.compactMap { $0 as? UIWindowScene } + return windowScenes.first { $0.activationState == .foregroundActive } ?? windowScenes.first +} + +@MainActor +final class TestWindow { + private let window: UIWindow + + init(rootViewController: UIViewController) { + if let windowScene = foregroundWindowScene() { + window = UIWindow(windowScene: windowScene) + } else { + window = Self.makeWindowWithoutScene() + } + + window.rootViewController = rootViewController + window.windowLevel = UIWindow.Level.alert + 1 + window.makeKeyAndVisible() + } + + var hasWindowScene: Bool { + window.windowScene != nil + } + + /// Package tests have no host app and therefore no window scene + private static func makeWindowWithoutScene() -> UIWindow { + #if os(visionOS) + // visionOS has no UIScreen, and a window without a scene is never shown there, + // so `waitUntilVisible` reports that a host app is needed + UIWindow() + #else + UIWindow(frame: UIScreen.main.bounds) + #endif + } + + func waitUntilVisible(_ hostController: HostViewController, at location: TestSourceLocation) async -> Bool { + guard await waitUntil({ hostController.hasAppeared }) else { + reportIssue("The test window could not be shown. Run the tests in a host app.", at: location) + return false + } + + return true + } + + func close() { + window.isHidden = true + window.rootViewController = nil + } +} + +final class HostViewController: UIViewController { + private(set) var hasAppeared = false + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + hasAppeared = true + } +} + +#elseif canImport(AppKit) + +// MARK: - AppKit + +public extension Lifecycle where Object: NSViewController { + /// Loads the view, so `viewDidLoad` runs + static var loadView: Self { + Self { controller, _ in + _ = controller.view + return true + } + } +} + +#endif diff --git a/Sources/DeallocTests/Expectation/Polling.swift b/Sources/DeallocTests/Expectation/Polling.swift new file mode 100644 index 0000000..790efbe --- /dev/null +++ b/Sources/DeallocTests/Expectation/Polling.swift @@ -0,0 +1,32 @@ +// +// Polling.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +enum Polling { + @MainActor + static func waitUntil( + timeout: Duration, + interval: Duration = .milliseconds(10), + _ condition: @MainActor () -> Bool + ) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now + timeout + + while !condition() { + guard clock.now < deadline else { + return false + } + + do { + try await Task.sleep(for: interval) + } catch { + return condition() + } + } + + return true + } +} diff --git a/Sources/DeallocTests/Expectation/TrackForDeallocation.swift b/Sources/DeallocTests/Expectation/TrackForDeallocation.swift new file mode 100644 index 0000000..60edef8 --- /dev/null +++ b/Sources/DeallocTests/Expectation/TrackForDeallocation.swift @@ -0,0 +1,130 @@ +// +// TrackForDeallocation.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import XCTest + +#if canImport(Testing) + import Testing +#endif + +// MARK: - XCTest + +public extension XCTestCase { + /// Checks that the object deallocates when the test ends. + /// + /// Keep the object in a local variable. A property of the test case lives until the test case is released. + /// The check uses the `DeallocationConfiguration` in effect where this method is called. + /// + /// ```swift + /// func test_viewModel() { + /// let viewModel = trackForDeallocation(ProfileViewModel()) + /// ... + /// } + /// ``` + @MainActor + @discardableResult + func trackForDeallocation( + _ object: Object, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) -> Object { + let location = TestSourceLocation(fileID: fileID, filePath: filePath, line: line, column: column) + + if let tracker = DeallocationTracker.current { + tracker.track(object, at: location) + return object + } + + let tracker = DeallocationTracker() + tracker.track(object, at: location) + + addTeardownBlock { @MainActor in + await tracker.verifyDeallocation() + } + + return object + } +} + +// MARK: - Swift Testing + +/// Checks that the object deallocates when the test ends. Requires the `.checksDeallocation` trait, +/// or a call inside `expectDeallocation`, which then checks the object together with the tested one. +/// +/// ```swift +/// @Test(.checksDeallocation) func viewModel() async { +/// let viewModel = trackForDeallocation(ProfileViewModel()) +/// ... +/// } +/// ``` +@MainActor +@discardableResult +public func trackForDeallocation( + _ object: Object, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column +) -> Object { + let location = TestSourceLocation(fileID: fileID, filePath: filePath, line: line, column: column) + + guard let tracker = DeallocationTracker.current else { + reportIssue( + "trackForDeallocation(_:) needs the .checksDeallocation trait on the test or its suite, " + + "or a call inside expectDeallocation. In XCTest, call it on the test case.", + at: location + ) + return object + } + + tracker.track(object, at: location) + return object +} + +#if canImport(Testing) + +/// Checks that every object passed to `trackForDeallocation(_:)` deallocates when the test ends +public struct DeallocationCheckTrait: TestTrait, SuiteTrait, TestScoping { + public var isRecursive: Bool { + true + } + + public func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: @Sendable () async throws -> Void + ) async throws { + guard !test.isSuite else { + try await function() + return + } + + let tracker = await DeallocationTracker() + + do { + try await DeallocationTracker.$current.withValue(tracker) { + try await function() + } + } catch { + await tracker.verifyDeallocation() + throw error + } + + await tracker.verifyDeallocation() + } +} + +public extension Trait where Self == DeallocationCheckTrait { + /// Checks that every object passed to `trackForDeallocation(_:)` deallocates when the test ends + static var checksDeallocation: Self { + Self() + } +} + +#endif diff --git a/Tests/DeallocTestsTests/ConfigurationTests.swift b/Tests/DeallocTestsTests/ConfigurationTests.swift new file mode 100644 index 0000000..7ac7f26 --- /dev/null +++ b/Tests/DeallocTestsTests/ConfigurationTests.swift @@ -0,0 +1,149 @@ +// +// ConfigurationTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import DeallocTests +import Foundation +import Testing + +final class RecordedComments: @unchecked Sendable { + private let lock = NSLock() + private var comments = [String]() + + func append(_ issue: Issue) { + lock.withLock { comments += issue.comments.map(\.rawValue) } + } + + func contains(_ text: String) -> Bool { + lock.withLock { comments.contains { $0.contains(text) } } + } +} + +func isLeakReport(within timeout: String) -> (Issue) -> Bool { + { issue in + issue.comments.contains { $0.rawValue.hasPrefix("DeallocTestsTests.RetainCycleObject was not deallocated within \(timeout).") } + } +} + +@Suite("Deallocation configuration", .deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.zero)) +@MainActor +struct ConfigurationTests { + @Test func suiteTimeoutApplies() async { + await withKnownIssue { + await expectDeallocation { RetainCycleObject() } + } matching: { issue in + isLeakReport(within: "100 ms")(issue) + } + } + + @Test(.deallocationTimeout(.milliseconds(300))) + func testTraitWinsOverSuiteTrait() async { + await withKnownIssue { + await expectDeallocation { RetainCycleObject() } + } matching: { issue in + isLeakReport(within: "300 ms")(issue) + } + } + + @Test func explicitTimeoutWinsOverTraits() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(50)) { RetainCycleObject() } + } matching: { issue in + isLeakReport(within: "50 ms")(issue) + } + } + + @Test func withDeallocationConfigurationChangesTheTimeout() async { + await withKnownIssue { + await withDeallocationConfiguration({ $0.timeout = .milliseconds(150) }) { + await expectDeallocation { RetainCycleObject() } + } + } matching: { issue in + isLeakReport(within: "150 ms")(issue) + } + } + + @Test(.deallocationIssues(.warning)) + func warningSeverityRecordsAWarning() async { + #if compiler(>=6.3) + await withKnownIssue { + await expectDeallocation { RetainCycleObject() } + } matching: { issue in + issue.severity == .warning && isLeakReport(within: "100 ms")(issue) + } + #else + await expectDeallocation { RetainCycleObject() } + #endif + } + + @Test func eachTrackedObjectUsesItsOwnConfiguration() async throws { + let test = try #require(Test.current) + let recorded = RecordedComments() + + try await withKnownIssue { + try await DeallocationCheckTrait.checksDeallocation.provideScope( + for: test, + testCase: Test.Case.current, + performing: { + await MainActor.run { + withDeallocationConfiguration({ $0.timeout = .milliseconds(150) }) { + _ = trackForDeallocation(RetainCycleObject()) + } + withDeallocationConfiguration({ $0.timeout = .milliseconds(250) }) { + _ = trackForDeallocation(RetainCycleObject()) + } + } + } + ) + } matching: { issue in + recorded.append(issue) + return isLeakReport(within: "150 ms")(issue) || isLeakReport(within: "250 ms")(issue) + } + + #expect(recorded.contains("within 150 ms")) + #expect(recorded.contains("within 250 ms")) + } + + @Test func trackedObjectsUseTheConfiguredTimeout() async throws { + try await withKnownIssue { + try await DeallocationCheckTrait.checksDeallocation.provideScope( + for: #require(Test.current), + testCase: Test.Case.current, + performing: { + await MainActor.run { + _ = trackForDeallocation(RetainCycleObject()) + } + } + ) + } matching: { issue in + isLeakReport(within: "100 ms")(issue) + } + } + + @Test func trackedObjectsUseTheConfigurationOfANestedTrait() async throws { + let test = try #require(Test.current) + + try await withKnownIssue { + try await DeallocationCheckTrait.checksDeallocation.provideScope( + for: test, + testCase: Test.Case.current, + performing: { + try await DeallocationConfigurationTrait.deallocationTimeout(.milliseconds(130)).provideScope( + for: test, + testCase: Test.Case.current, + performing: { + await MainActor.run { + _ = trackForDeallocation(RetainCycleObject()) + } + } + ) + } + ) + } matching: { issue in + isLeakReport(within: "130 ms")(issue) + } + } +} diff --git a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift new file mode 100644 index 0000000..430d40d --- /dev/null +++ b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift @@ -0,0 +1,313 @@ +// +// ExpectDeallocationTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +@testable import DeallocTests +import Testing + +#if DependencyInjection + import DependencyInjection +#endif + +#if canImport(AppKit) + import AppKit +#endif + +// MARK: - Fixtures + +final class PlainObject {} + +final class RetainCycleObject { + var closure: (() -> Void)? + + init() { + closure = { _ = self } + } +} + +protocol AnyService: Sendable {} + +protocol Service: AnyObject, Sendable {} + +final class ContainerService: Service {} + +struct ValueService: AnyService {} + +@MainActor +final class Cache { + var objects = [AnyObject]() +} + +struct LifecycleError: Error {} + +private final class PrivateRetainCycle { + var closure: (() -> Void)? + + init() { + closure = { _ = self } + } +} + +func isLeakReport(of typeName: String) -> (Issue) -> Bool { + { issue in + issue.comments.contains { $0.rawValue.contains("\(typeName) was not deallocated") } + && issue.sourceLocation?.fileName.hasSuffix("Tests.swift") == true + } +} + +// MARK: - expectDeallocation + +@Suite("expectDeallocation") +@MainActor +struct ExpectDeallocationTests { + @Test func cancelledCheckReportsNothing() async { + let check = Task { @MainActor in + await expectDeallocation(timeout: .seconds(5)) { RetainCycleObject() } + } + + try? await Task.sleep(for: .milliseconds(100)) + check.cancel() + await check.value + } + + @Test func cancelledInteractionReportsNothing() async { + let check = Task { @MainActor in + await expectDeallocation(.custom { _ in try await Task.sleep(for: .seconds(5)) }) { PlainObject() } + } + + try? await Task.sleep(for: .milliseconds(100)) + check.cancel() + await check.value + } + + @Test func afterReleaseRunsWhenTheLifecycleCannotRun() async { + var didRunAfterRelease = false + + await expectDeallocation(Lifecycle { _, _ in false }, afterRelease: { didRunAfterRelease = true }) { PlainObject() } + + #expect(didRunAfterRelease) + } + + @Test func cleanObjectPasses() async { + await expectDeallocation { PlainObject() } + } + + @Test func retainCycleIsReportedAtCallSite() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { RetainCycleObject() } + } matching: { issue in + isLeakReport(of: "RetainCycleObject")(issue) && issue.sourceLocation?.line == #line - 2 + } + } + + @Test func privateTypeNameIsReadable() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { PrivateRetainCycle() } + } matching: { issue in + issue.comments.contains { comment in + comment.rawValue.hasPrefix("DeallocTestsTests.PrivateRetainCycle was not deallocated") + } + } + } + + @Test func customLifecycleRunsBeforeRelease() async { + var exercisedObject: ObjectIdentifier? + + await expectDeallocation(.custom { exercisedObject = ObjectIdentifier($0) }) { PlainObject() } + + #expect(exercisedObject != nil) + } + + @Test func customLifecycleLeakIsReported() async { + let cache = Cache() + + await withKnownIssue { + await expectDeallocation(.custom { cache.objects.append($0) }, timeout: .milliseconds(100)) { PlainObject() } + } matching: { issue in + isLeakReport(of: "PlainObject")(issue) + } + } + + @Test func throwingLifecycleIsReported() async { + await withKnownIssue { + await expectDeallocation(.custom { _ in throw LifecycleError() }) { PlainObject() } + } matching: { issue in + issue.comments.contains { $0.rawValue.contains("threw an error") } + } + } + + @Test func interactionThrowingCancellationErrorIsReported() async { + await withKnownIssue { + await expectDeallocation(.custom { _ in + // A child throws CancellationError while the test task itself is still active + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { throw CancellationError() } + try await group.waitForAll() + } + }) { PlainObject() } + } matching: { issue in + issue.comments.contains { $0.rawValue.contains("threw an error: CancellationError") } + } + } + + @Test func throwingFactoryRethrows() async { + await #expect(throws: LifecycleError.self) { + try await expectDeallocation { () throws -> PlainObject in throw LifecycleError() } + } + } + + @Test func afterReleaseRunsBeforeCheck() async { + let cache = Cache() + + await expectDeallocation(afterRelease: { cache.objects.removeAll() }) { + let object = PlainObject() + cache.objects.append(object) + return object + } + } + + #if canImport(AppKit) + @Test func appKitLoadViewRunsViewDidLoad() async { + await withKnownIssue { + await expectDeallocation(.loadView, timeout: .milliseconds(100)) { LeakingViewController() } + } matching: { issue in + isLeakReport(of: "LeakingViewController")(issue) + } + } + + @Test func appKitCleanControllerPasses() async { + await expectDeallocation(.loadView) { CleanViewController() } + } + #endif +} + +#if canImport(AppKit) + final class CleanViewController: NSViewController { + override func loadView() { + view = NSView() + } + } + + final class LeakingViewController: NSViewController { + var closure: (() -> Void)? + + override func loadView() { + view = NSView() + } + + override func viewDidLoad() { + super.viewDidLoad() + closure = { _ = self } + } + } +#endif + +// MARK: - Dependency Injection + +#if DependencyInjection + +@Suite("expectDeallocation with AsyncContainer") +@MainActor +struct ExpectDeallocationDependencyInjectionTests { + let container = AsyncContainer() + + @Test func sharedInstanceIsReleasedWithContainer() async { + await container.register(type: Service.self, in: .shared) { _ in ContainerService() } + + await expectDeallocation(of: Service.self, resolvedFrom: container) + } + + @Test func newInstanceIsChecked() async { + await container.register(type: Service.self, in: .new) { _ in ContainerService() } + + await expectDeallocation(of: Service.self, resolvedFrom: container) + } + + @Test func valueTypeIsReported() async { + await container.register(type: AnyService.self, in: .new) { _ in ValueService() } + + await withKnownIssue { + await expectDeallocation(of: AnyService.self, resolvedFrom: container) + } matching: { issue in + issue.comments.contains { $0.rawValue.hasPrefix("ValueService resolved for AnyService is a value type") } + } + } +} + +#endif + +// MARK: - trackForDeallocation + +@Suite("trackForDeallocation") +@MainActor +struct TrackForDeallocationTests { + @Test(.checksDeallocation) func trackedObjectPasses() { + let object = trackForDeallocation(PlainObject()) + _ = object + } + + @Test func trackedLeakIsReported() async throws { + try await withKnownIssue { + try await DeallocationCheckTrait.checksDeallocation.provideScope( + for: #require(Test.current), + testCase: Test.Case.current, + performing: { + await MainActor.run { + withDeallocationConfiguration({ $0.timeout = .milliseconds(100) }) { + _ = trackForDeallocation(RetainCycleObject()) + } + } + } + ) + } matching: { issue in + isLeakReport(of: "RetainCycleObject")(issue) + } + } + + @Test func trackedLeakIsReportedWhenTheTestThrows() async throws { + let test = try #require(Test.current) + + await withKnownIssue { + _ = try? await DeallocationCheckTrait.checksDeallocation.provideScope( + for: test, + testCase: Test.Case.current, + performing: { + await MainActor.run { + withDeallocationConfiguration({ $0.timeout = .milliseconds(100) }) { + _ = trackForDeallocation(RetainCycleObject()) + } + } + throw LifecycleError() + } + ) + } matching: { issue in + isLeakReport(of: "RetainCycleObject")(issue) + } + } + + @Test func missingTraitIsReported() { + withKnownIssue { + _ = trackForDeallocation(PlainObject()) + } matching: { issue in + issue.comments.contains { $0.rawValue.contains("needs the .checksDeallocation trait") } + } + } +} + +@Suite("trackForDeallocation on a suite", .checksDeallocation) +@MainActor +struct TrackForDeallocationSuiteTests { + let object = trackForDeallocation(PlainObject()) + + @Test func storedPropertyIsChecked() { + _ = object + } + + @Test(arguments: [1, 2, 3]) + func parameterizedTestIsChecked(value: Int) { + _ = trackForDeallocation(PlainObject()) + } +} diff --git a/Tests/DeallocTestsTests/GracePeriodTests.swift b/Tests/DeallocTestsTests/GracePeriodTests.swift new file mode 100644 index 0000000..cad9a32 --- /dev/null +++ b/Tests/DeallocTestsTests/GracePeriodTests.swift @@ -0,0 +1,71 @@ +// +// GracePeriodTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +@testable import DeallocTests +import Foundation +import Testing + +@MainActor +func makeObjectReleasedAfter(_ delay: Duration) -> PlainObject { + let object = PlainObject() + Task { @MainActor in + try? await Task.sleep(for: delay) + withExtendedLifetime(object) {} + } + return object +} + +@Suite("Grace period") +@MainActor +struct GracePeriodTests { + // A passing check ends as soon as the object goes away, so the long grace period only + // matters on a loaded runner, where the release can come seconds late + @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.seconds(60))) + func lateReleaseIsAWarning() async { + #if compiler(>=6.3) + await withKnownIssue { + await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + } matching: { issue in + issue.severity == .warning + && issue.comments.contains { $0.rawValue.hasPrefix("DeallocTestsTests.PlainObject was released after") } + } + #else + await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + #endif + } + + @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.milliseconds(300))) + func leakIsStillAFailureAfterTheGracePeriod() async { + await withKnownIssue { + await expectDeallocation { RetainCycleObject() } + } matching: { issue in + issue.comments.contains { + $0.rawValue.hasPrefix( + "DeallocTestsTests.RetainCycleObject was not deallocated within 100 ms. It was watched for another 300 ms after that." + ) + } + } + } + + @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.zero)) + func zeroGracePeriodTurnsItOff() async { + await withKnownIssue { + await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + } matching: { issue in + issue.comments.contains { $0.rawValue.hasPrefix("DeallocTestsTests.PlainObject was not deallocated within 100 ms. Something") } + } + } + + @Test func warningSeveritySkipsTheGracePeriod() { + var configuration = DeallocationConfiguration() + configuration.gracePeriod = .seconds(3) + #expect(configuration.effectiveGracePeriod == .seconds(3)) + + configuration.severity = .warning + #expect(configuration.effectiveGracePeriod == .zero) + } +} diff --git a/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift b/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift new file mode 100644 index 0000000..45300f3 --- /dev/null +++ b/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift @@ -0,0 +1,255 @@ +// +// LeakHintsAndSwiftUITests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import Combine +import Observation +import DeallocTests +import SwiftUI +import Testing + +// MARK: - Fixtures + +final class ClosureLeak { + var onUpdate: (() -> Void)? + + init() { + onUpdate = { _ = self } + } +} + +@available(macOS 14, iOS 17, *) +@Observable +final class ObservableClosureLeak { + var onUpdate: (() -> Void)? + + init() { + onUpdate = { _ = self } + } +} + +final class CycleParent { + var child: CycleChild? + + init() { + child = CycleChild(parent: self) + } +} + +final class CycleChild { + let parent: CycleParent + + init(parent: CycleParent) { + self.parent = parent + } +} + +final class SubscriptionLeak { + let updates = PassthroughSubject() + var cancellables = Set() + var value = 0 + + init() { + updates.sink { self.value = $0 }.store(in: &cancellables) + } +} + +final class OwnerObject { + let viewModel: PlainObject + + init(viewModel: PlainObject) { + self.viewModel = viewModel + } +} + +@MainActor +final class TaskLeakModel { + var task: Task? + var ticks = 0 + + func start() { + task = Task { + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(10)) + self.ticks += 1 + } + } + } +} + +@MainActor +final class TaskModifierModel { + var ticks = 0 + var appeared = false + + func run() async { + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(10)) + ticks += 1 + } + } +} + +struct TaskLeakView: View { + let model: TaskLeakModel + + var body: some View { + Text("Leaking").onAppear { model.start() } + } +} + +struct TaskModifierView: View { + let model: TaskModifierModel + + var body: some View { + Text("Clean") + .onAppear { model.appeared = true } + .task { await model.run() } + } +} + +func isLeakReport(of typeName: String, mentioning hint: String) -> (Issue) -> Bool { + { issue in + isLeakReport(of: typeName)(issue) && issue.comments.contains { $0.rawValue.contains(hint) } + } +} + +final class SelfReference { + var me: SelfReference? + + init() { + me = self + } +} + +// MARK: - Leak hints + +@Suite("Leak hints") +@MainActor +struct LeakHintsTests { + @Test func closurePropertyIsNamed() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { ClosureLeak() } + } matching: { issue in + isLeakReport(of: "ClosureLeak", mentioning: "`onUpdate` is a closure")(issue) + } + } + + @Test @available(macOS 14, iOS 17, *) + func observablePropertyNamesAreReadable() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { ObservableClosureLeak() } + } matching: { issue in + isLeakReport(of: "ObservableClosureLeak", mentioning: "• `onUpdate` is a closure")(issue) + && !issue.comments.contains { $0.rawValue.contains("observationRegistrar") } + } + } + + @Test func propertyCycleIsShown() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { CycleParent() } + } matching: { issue in + isLeakReport(of: "CycleParent", mentioning: "`self.child.parent` refers back to the object")(issue) + } + } + + @Test func directSelfReferenceIsShown() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { SelfReference() } + } matching: { issue in + isLeakReport(of: "SelfReference", mentioning: "`self.me` refers back to the object")(issue) + } + } + + @Test func subscriptionIsNamed() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { SubscriptionLeak() } + } matching: { issue in + isLeakReport(of: "SubscriptionLeak", mentioning: "`cancellables` is a Combine subscription")(issue) + } + } + + @Test func hintsMentionExternalOwners() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { ClosureLeak() } + } matching: { issue in + isLeakReport(of: "ClosureLeak", mentioning: "something outside still holds it")(issue) + } + } + + @Test func leakWithoutSuspectsGetsGenericMessage() async { + let cache = Cache() + + await withKnownIssue { + await expectDeallocation(.custom { cache.objects.append($0) }, timeout: .milliseconds(100)) { PlainObject() } + } matching: { issue in + isLeakReport(of: "PlainObject", mentioning: "Something still holds a strong reference")(issue) + } + } +} + +// MARK: - Tracking objects inside expectDeallocation + +@Suite("trackForDeallocation inside expectDeallocation") +@MainActor +struct NestedTrackingTests { + @Test func trackedChildPasses() async { + await expectDeallocation { + OwnerObject(viewModel: trackForDeallocation(PlainObject())) + } + } + + @Test func leakedChildIsReported() async { + let cache = Cache() + + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { + let viewModel = trackForDeallocation(PlainObject()) + cache.objects.append(viewModel) + return OwnerObject(viewModel: viewModel) + } + } matching: { issue in + isLeakReport(of: "PlainObject")(issue) + } + } +} + +// MARK: - SwiftUI + +@Suite("SwiftUI hosting", .serialized) +@MainActor +struct SwiftUIHostingTests { + @Test func taskModifierIsCancelledWithView() async { + var appeared = false + + await expectDeallocation(.hosting { TaskModifierView(model: $0) }) { + let model = TaskModifierModel() + return model + } + + await expectDeallocation(.hosting(interaction: { appeared = $0.appeared }) { TaskModifierView(model: $0) }) { + TaskModifierModel() + } + + #expect(appeared) + } + + @Test func taskStartedOnAppearLeaks() async { + weak var leakedModel: TaskLeakModel? + + await withKnownIssue { + await expectDeallocation(.hosting { TaskLeakView(model: $0) }, timeout: .milliseconds(200)) { + let model = TaskLeakModel() + leakedModel = model + return model + } + } matching: { issue in + isLeakReport(of: "TaskLeakModel", mentioning: "`task` is a task")(issue) + } + + leakedModel?.task?.cancel() + } +} diff --git a/Tests/DeallocTestsTests/LeakReportTests.swift b/Tests/DeallocTestsTests/LeakReportTests.swift new file mode 100644 index 0000000..520c528 --- /dev/null +++ b/Tests/DeallocTestsTests/LeakReportTests.swift @@ -0,0 +1,109 @@ +// +// LeakReportTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import Combine +@testable import DeallocTests +import Foundation +import Testing + +private final class PrivateObject {} + +final class ClosureHolder { + var onUpdate: (() -> Void)? +} + +final class TimerService {} + +final class UnusedOptionals { + var onUpdate: (() -> Void)? + var timer: Timer? + var task: Task? + var cancellables = Set() + let timerService = TimerService() +} + +final class TimerHolder { + var timer: Timer? = Timer(timeInterval: 60, repeats: false) { _ in } +} + +@Suite("Leak reports") +struct LeakReportTests { + @Test func readableNameDropsUnknownContext() { + let name = TypeNames.readableName(of: PrivateObject()) + + #expect(name == "DeallocTestsTests.PrivateObject") + } + + @Test func messageWithoutHintsGivesGeneralAdvice() { + let report = LeakReport(typeName: "App.Screen", timeout: .seconds(2)) + + #expect(report.message.hasPrefix("App.Screen was not deallocated within 2 sec. Something still holds")) + } + + @Test func messageWithHintsListsCausesAndExternalOwners() { + let report = LeakReport(typeName: "App.Screen", timeout: .milliseconds(500), hints: ["`onUpdate` is a closure"]) + + #expect(report.message == """ + App.Screen was not deallocated within 500 ms. Possible causes: + • `onUpdate` is a closure + • Or something outside still holds it: a parent's list of children, a cache or a singleton + """) + } + + @Test(arguments: [ + (Duration.milliseconds(400), "400 ms"), + (.seconds(2), "2 sec"), + (.milliseconds(3250), "3.2 sec"), + (.milliseconds(1999), "1.9 sec"), + ]) + func durationsReadTheSameInEveryLocale(duration: Duration, text: String) { + #expect(DurationText.describe(duration) == text) + } + + @Test func lateReleaseMessageExplainsBoundedRetention() { + let message = LeakReport.lateReleaseMessage(typeName: "App.Store", releasedAfter: .milliseconds(3250), timeout: .seconds(2)) + + #expect(message.hasPrefix("App.Store was released after 3.2 sec, later than the 2 sec timeout. That's bounded retention, not a leak")) + } + + @Test func messageMentionsTheGracePeriod() { + let report = LeakReport(typeName: "App.Screen", timeout: .seconds(2), gracePeriod: .seconds(3)) + + #expect(report.message.hasPrefix("App.Screen was not deallocated within 2 sec. It was watched for another 3 sec after that.")) + } + + @Test func hintsNameClosureProperties() { + let holder = ClosureHolder() + holder.onUpdate = {} + + #expect(LeakHints.hints(for: holder).contains { $0.hasPrefix("`onUpdate` is a closure") }) + } + + @Test func hintsSkipEmptyOptionalsAndCollections() { + #expect(LeakHints.hints(for: UnusedOptionals()).isEmpty) + } + + @Test func hintsNameTimersByType() { + #expect(LeakHints.hints(for: TimerHolder()).contains { $0.hasPrefix("`timer` is a timer") }) + } + + @Test @MainActor func pollingReturnsAsSoonAsTheConditionHolds() async { + let clock = ContinuousClock() + let start = clock.now + + let result = await Polling.waitUntil(timeout: .seconds(5)) { true } + + #expect(result) + #expect(clock.now - start < .seconds(1)) + } + + @Test @MainActor func pollingGivesUpAfterTheTimeout() async { + let result = await Polling.waitUntil(timeout: .milliseconds(50)) { false } + + #expect(!result) + } +} diff --git a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift new file mode 100644 index 0000000..ae992e0 --- /dev/null +++ b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift @@ -0,0 +1,138 @@ +// +// TrackForDeallocationXCTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import DeallocTests +import XCTest + +final class ExpectedFailureRecorder: NSObject, XCTestObservation, @unchecked Sendable { + private(set) var descriptions = [String]() + + func testCase(_ testCase: XCTestCase, didRecord expectedFailure: XCTExpectedFailure) { + descriptions.append(expectedFailure.issue.compactDescription) + } +} + +final class TrackForDeallocationXCTests: XCTestCase { + @MainActor + private func recordingExpectedFailures(_ operation: () async -> Void) async -> [String] { + let recorder = ExpectedFailureRecorder() + XCTestObservationCenter.shared.addTestObserver(recorder) + await operation() + XCTestObservationCenter.shared.removeTestObserver(recorder) + return recorder.descriptions + } + + override func invokeTest() { + withDeallocationConfiguration({ $0.gracePeriod = .zero }) { + super.invokeTest() + } + } + + @MainActor + func test_invokeTestConfiguration_appliesToTheTest() async { + let options = XCTExpectedFailure.Options() + options.issueMatcher = { issue in + issue.compactDescription.contains("within 100 ms.") && !issue.compactDescription.contains("watched for another") + } + XCTExpectFailure("RetainCycleObject has a retain cycle", options: options) + + await expectDeallocation(timeout: .milliseconds(100)) { RetainCycleObject() } + } + + @MainActor + func test_trackedObject_passes() { + let object = trackForDeallocation(PlainObject()) + _ = object + } + + @MainActor + func test_trackedLeak_fails() { + XCTExpectFailure("RetainCycleObject has a retain cycle") + + withDeallocationConfiguration({ $0.timeout = .milliseconds(100) }) { + _ = trackForDeallocation(RetainCycleObject()) + } + } + + @MainActor + func test_expectDeallocation_cleanObject_passes() async { + await expectDeallocation { PlainObject() } + } + + @MainActor + func test_expectDeallocation_leak_fails() async { + XCTExpectFailure("RetainCycleObject has a retain cycle") + + await expectDeallocation(timeout: .milliseconds(100)) { RetainCycleObject() } + } + + @MainActor + func test_trackedChildInsideExpectDeallocation_isCheckedWithIt() async { + XCTExpectFailure("The child is kept alive by the cache") + let cache = Cache() + + await expectDeallocation(timeout: .milliseconds(100)) { + let child = trackForDeallocation(PlainObject()) + cache.objects.append(child) + return OwnerObject(viewModel: child) + } + } + + @MainActor + func test_withDeallocationConfiguration_changesTheTimeout() async { + let options = XCTExpectedFailure.Options() + options.issueMatcher = { $0.compactDescription.contains("within 120 ms") } + XCTExpectFailure("RetainCycleObject has a retain cycle", options: options) + + await withDeallocationConfiguration({ $0.timeout = .milliseconds(120) }) { + await expectDeallocation { RetainCycleObject() } + } + } + + @MainActor + func test_trackedObject_usesTheConfigurationItWasTrackedWith() { + let options = XCTExpectedFailure.Options() + options.issueMatcher = { $0.compactDescription.contains("within 130 ms") } + XCTExpectFailure("RetainCycleObject has a retain cycle", options: options) + + withDeallocationConfiguration({ + $0.timeout = .milliseconds(130) + $0.gracePeriod = .zero + }) { + _ = trackForDeallocation(RetainCycleObject()) + } + } + + @MainActor + func test_warningSeverity_isReportedAsAnExpectedFailure() async { + let warnings = await recordingExpectedFailures { + await withDeallocationConfiguration({ + $0.timeout = .milliseconds(100) + $0.severity = .warning + }) { + await expectDeallocation { RetainCycleObject() } + } + } + + XCTAssertTrue(warnings.contains { $0.contains("RetainCycleObject was not deallocated within 100 ms") }, "\(warnings)") + } + + @MainActor + func test_lateRelease_isAWarning() async { + let warnings = await recordingExpectedFailures { + await withDeallocationConfiguration({ + $0.timeout = .milliseconds(100) + // Long on purpose: a passing check ends when the object goes away, and a loaded runner can release it late + $0.gracePeriod = .seconds(60) + }) { + await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + } + } + + XCTAssertTrue(warnings.contains { $0.contains("PlainObject was released after") }, "\(warnings)") + } +} diff --git a/Tests/DeallocTestsTests/UIKitLifecycleTests.swift b/Tests/DeallocTestsTests/UIKitLifecycleTests.swift new file mode 100644 index 0000000..90047ef --- /dev/null +++ b/Tests/DeallocTestsTests/UIKitLifecycleTests.swift @@ -0,0 +1,103 @@ +// +// UIKitLifecycleTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +#if canImport(UIKit) + +import DeallocTests +import Testing +import UIKit + +final class CleanController: UIViewController {} + +final class LoadLeakingController: UIViewController { + var closure: (() -> Void)? + + override func viewDidLoad() { + super.viewDidLoad() + closure = { _ = self } + } +} + +final class AppearLeakingController: UIViewController { + var closure: (() -> Void)? + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + closure = { _ = self } + } +} + +@MainActor +func hasWindowScene() -> Bool { + UIApplication.shared.connectedScenes.contains { $0 is UIWindowScene } +} + +@Suite("UIKit lifecycles", .serialized) +@MainActor +struct UIKitLifecycleTests { + @Test func loadViewCatchesViewDidLoadLeak() async { + await withKnownIssue { + await expectDeallocation(.loadView, timeout: .milliseconds(200)) { LoadLeakingController() } + } matching: { issue in + isLeakReport(of: "LoadLeakingController")(issue) + } + } + + @Test func loadViewMissesAppearLeak() async { + await expectDeallocation(.loadView) { AppearLeakingController() } + } + + @Test(.enabled("Modal presentation needs a host app") { await hasWindowScene() }) func presentPassesForCleanController() async { + await expectDeallocation(.present) { CleanController() } + } + + @Test(.enabled("Modal presentation needs a host app") { await hasWindowScene() }) func presentCatchesAppearLeak() async { + await withKnownIssue { + await expectDeallocation(.present, timeout: .milliseconds(200)) { AppearLeakingController() } + } matching: { issue in + isLeakReport(of: "AppearLeakingController")(issue) + } + } + + @Test(.enabled("Modal presentation needs a host app") { await hasWindowScene() }) func presentAsPopoverIsAnchored() async { + await expectDeallocation(.present(style: .popover)) { CleanController() } + } + + @Test(.enabled("Modal presentation needs a host app") { await hasWindowScene() }) func presentInteractionRunsOnScreen() async { + var wasOnScreen = false + + await expectDeallocation(.present(style: .pageSheet, interaction: { wasOnScreen = $0.view.window != nil })) { + CleanController() + } + + #expect(wasOnScreen) + } + + @Test func pushPassesForCleanController() async { + await expectDeallocation(.push) { CleanController() } + } + + @Test func pushCatchesAppearLeak() async { + await withKnownIssue { + await expectDeallocation(.push, timeout: .milliseconds(200)) { AppearLeakingController() } + } matching: { issue in + isLeakReport(of: "AppearLeakingController")(issue) + } + } + + @Test func pushInteractionRunsInNavigationController() async { + var wasPushed = false + + await expectDeallocation(.push(interaction: { wasPushed = $0.navigationController != nil })) { + CleanController() + } + + #expect(wasPushed) + } +} + +#endif