diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f32c28..7a1c6f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,17 @@ jobs: - 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 tests (iOS Simulator) runs-on: macos-15 diff --git a/Sources/DeallocTests/Diagnostics/LeakReport.swift b/Sources/DeallocTests/Diagnostics/LeakReport.swift index 7942d0e..f755f4e 100644 --- a/Sources/DeallocTests/Diagnostics/LeakReport.swift +++ b/Sources/DeallocTests/Diagnostics/LeakReport.swift @@ -8,16 +8,27 @@ struct LeakReport: Sendable { let typeName: String let timeout: Duration + let gracePeriod: Duration let hints: [String] - init(typeName: String, timeout: Duration, 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 { - let summary = "\(typeName) was not deallocated within \(DurationText.describe(timeout))." + 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, " 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 index 50ca678..56ffece 100644 --- a/Sources/DeallocTests/Expectation/DeallocationTracker.swift +++ b/Sources/DeallocTests/Expectation/DeallocationTracker.swift @@ -5,12 +5,20 @@ // 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? @@ -19,34 +27,86 @@ final class DeallocationTracker { func track(_ object: AnyObject, at location: TestSourceLocation) { trackedObjects.append( - TrackedObject(object: object, typeName: TypeNames.readableName(of: object), location: location) + TrackedObject( + object: object, + typeName: TypeNames.readableName(of: object), + location: location, + configuration: DeallocationConfiguration.current + ) ) } - func verifyDeallocation(timeout: Duration = .seconds(2)) async { - let objects = trackedObjects + 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() - _ = await Polling.waitUntil(timeout: timeout) { - !objects.contains { $0.object != nil } + 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 trackedObject in objects { - guard let object = trackedObject.object else { + 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: trackedObject.typeName, - timeout: timeout, + typeName: check.trackedObject.typeName, + timeout: check.timeout, + gracePeriod: check.gracePeriod, hints: LeakHints.hints(for: object) ).message, - at: trackedObject.location + 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 index 5dc2957..1698d5c 100644 --- a/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift +++ b/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift @@ -24,12 +24,12 @@ import DependencyInjection /// - 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 +/// - 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 = .seconds(2), + timeout: Duration? = nil, fileID: StaticString = #fileID, filePath: StaticString = #filePath, line: UInt = #line, diff --git a/Sources/DeallocTests/Expectation/ExpectDeallocation.swift b/Sources/DeallocTests/Expectation/ExpectDeallocation.swift index 3132281..ca78a62 100644 --- a/Sources/DeallocTests/Expectation/ExpectDeallocation.swift +++ b/Sources/DeallocTests/Expectation/ExpectDeallocation.swift @@ -20,14 +20,14 @@ /// /// - 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 +/// - 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 = .seconds(2), + timeout: Duration? = nil, afterRelease: @MainActor () async -> Void = {}, fileID: StaticString = #fileID, filePath: StaticString = #filePath, diff --git a/Sources/DeallocTests/Expectation/IssueReporting.swift b/Sources/DeallocTests/Expectation/IssueReporting.swift index 8d05970..1187005 100644 --- a/Sources/DeallocTests/Expectation/IssueReporting.swift +++ b/Sources/DeallocTests/Expectation/IssueReporting.swift @@ -18,24 +18,33 @@ struct TestSourceLocation: Sendable { let column: UInt } -func reportIssue(_ message: String, at location: TestSourceLocation) { +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) + recordSwiftTestingIssue(message, at: location, severity: severity) return } #endif - XCTFail(message, file: location.filePath, line: location.line) + 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) { +private func recordSwiftTestingIssue(_ message: String, at location: TestSourceLocation, severity: DeallocationIssueSeverity) { let sourceLocation = SourceLocation( fileID: String(describing: location.fileID), filePath: String(describing: location.filePath), @@ -43,7 +52,18 @@ private func recordSwiftTestingIssue(_ message: String, at location: TestSourceL column: Int(location.column) ) - Issue.record(Comment(rawValue: message), sourceLocation: sourceLocation) + 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/TrackForDeallocation.swift b/Sources/DeallocTests/Expectation/TrackForDeallocation.swift index 502e421..60edef8 100644 --- a/Sources/DeallocTests/Expectation/TrackForDeallocation.swift +++ b/Sources/DeallocTests/Expectation/TrackForDeallocation.swift @@ -17,6 +17,7 @@ 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() { 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 index 0ab541a..5c7fdea 100644 --- a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift +++ b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift @@ -242,7 +242,9 @@ struct TrackForDeallocationTests { testCase: Test.Case.current, performing: { await MainActor.run { - _ = trackForDeallocation(RetainCycleObject()) + withDeallocationConfiguration({ $0.timeout = .milliseconds(100) }) { + _ = trackForDeallocation(RetainCycleObject()) + } } } ) @@ -260,7 +262,9 @@ struct TrackForDeallocationTests { testCase: Test.Case.current, performing: { await MainActor.run { - _ = trackForDeallocation(RetainCycleObject()) + withDeallocationConfiguration({ $0.timeout = .milliseconds(100) }) { + _ = trackForDeallocation(RetainCycleObject()) + } } throw LifecycleError() } diff --git a/Tests/DeallocTestsTests/GracePeriodTests.swift b/Tests/DeallocTestsTests/GracePeriodTests.swift new file mode 100644 index 0000000..368fa1f --- /dev/null +++ b/Tests/DeallocTestsTests/GracePeriodTests.swift @@ -0,0 +1,69 @@ +// +// 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 { + @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.seconds(2))) + 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/LeakReportTests.swift b/Tests/DeallocTestsTests/LeakReportTests.swift index a559c80..520c528 100644 --- a/Tests/DeallocTestsTests/LeakReportTests.swift +++ b/Tests/DeallocTestsTests/LeakReportTests.swift @@ -64,6 +64,18 @@ struct LeakReportTests { #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 = {} diff --git a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift index 95e91d8..6a61d4b 100644 --- a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift +++ b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift @@ -8,7 +8,41 @@ 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()) @@ -19,7 +53,9 @@ final class TrackForDeallocationXCTests: XCTestCase { func test_trackedLeak_fails() { XCTExpectFailure("RetainCycleObject has a retain cycle") - trackForDeallocation(RetainCycleObject()) + withDeallocationConfiguration({ $0.timeout = .milliseconds(100) }) { + _ = trackForDeallocation(RetainCycleObject()) + } } @MainActor @@ -45,4 +81,57 @@ final class TrackForDeallocationXCTests: XCTestCase { 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) + $0.gracePeriod = .seconds(2) + }) { + await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + } + } + + XCTAssertTrue(warnings.contains { $0.contains("PlainObject was released after") }, "\(warnings)") + } }