From 1b38fe8ae4192053750a8764ff405bf6fdb8bd76 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 13 Apr 2023 14:10:32 -0700 Subject: [PATCH] Support for release builds We were under the impression that XCTestDynamicOverlay's code could lead to App Store rejections, but it sounds like that report was a false positive. Let's lift our current restriction to unlock the ability to use XCTestDynamicOverlay in release builds. --- README.md | 3 - .../Internal/XCTCurrentTestCase.swift | 42 ++-- Sources/XCTestDynamicOverlay/XCTFail.swift | 238 ++++++++---------- .../HostAppDetectionTests.swift | 2 +- 4 files changed, 124 insertions(+), 161 deletions(-) diff --git a/README.md b/README.md index 31cedad6..59815c9f 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,6 @@ import XCTestDynamicOverlay // ✅ …and your application or library will continue to compile just fine. -> ⚠️ Important: The dynamically loaded `XCTFail` is only available in `DEBUG` builds in order -to prevent App Store rejections due to runtime loading of symbols. - ## Example diff --git a/Sources/XCTestDynamicOverlay/Internal/XCTCurrentTestCase.swift b/Sources/XCTestDynamicOverlay/Internal/XCTCurrentTestCase.swift index b884f06c..ab725a77 100644 --- a/Sources/XCTestDynamicOverlay/Internal/XCTCurrentTestCase.swift +++ b/Sources/XCTestDynamicOverlay/Internal/XCTCurrentTestCase.swift @@ -1,28 +1,22 @@ -#if DEBUG - #if canImport(ObjectiveC) - import Foundation +#if canImport(ObjectiveC) + import Foundation - @_spi(CurrentTestCase) public var XCTCurrentTestCase: AnyObject? { - guard - let XCTestObservationCenter = NSClassFromString("XCTestObservationCenter"), - let XCTestObservationCenter = XCTestObservationCenter as Any as? NSObjectProtocol, - let shared = XCTestObservationCenter.perform(Selector(("sharedTestObservationCenter")))? - .takeUnretainedValue(), - let observers = shared.perform(Selector(("observers")))? - .takeUnretainedValue() as? [AnyObject], - let observer = - observers - .first(where: { NSStringFromClass(type(of: $0)) == "XCTestMisuseObserver" }), - let currentTestCase = observer.perform(Selector(("currentTestCase")))? - .takeUnretainedValue() - else { return nil } - return currentTestCase - } - #else - @_spi(CurrentTestCase) public var XCTCurrentTestCase: AnyObject? { - nil - } - #endif + @_spi(CurrentTestCase) public var XCTCurrentTestCase: AnyObject? { + guard + let XCTestObservationCenter = NSClassFromString("XCTestObservationCenter"), + let XCTestObservationCenter = XCTestObservationCenter as Any as? NSObjectProtocol, + let shared = XCTestObservationCenter.perform(Selector(("sharedTestObservationCenter")))? + .takeUnretainedValue(), + let observers = shared.perform(Selector(("observers")))? + .takeUnretainedValue() as? [AnyObject], + let observer = + observers + .first(where: { NSStringFromClass(type(of: $0)) == "XCTestMisuseObserver" }), + let currentTestCase = observer.perform(Selector(("currentTestCase")))? + .takeUnretainedValue() + else { return nil } + return currentTestCase + } #else @_spi(CurrentTestCase) public var XCTCurrentTestCase: AnyObject? { nil diff --git a/Sources/XCTestDynamicOverlay/XCTFail.swift b/Sources/XCTestDynamicOverlay/XCTFail.swift index 4b6f7eba..fe21060d 100644 --- a/Sources/XCTestDynamicOverlay/XCTFail.swift +++ b/Sources/XCTestDynamicOverlay/XCTFail.swift @@ -1,159 +1,131 @@ import Foundation -#if DEBUG - #if canImport(ObjectiveC) - /// This function generates a failure immediately and unconditionally. - /// - /// Dynamically creates and records an `XCTIssue` under the hood that captures the source code - /// context of the caller. Useful for defining assertion helpers that fail in indirect code - /// paths, where the `file` and `line` of the failure have not been realized. - /// - /// - Parameter message: An optional description of the assertion, for inclusion in test - /// results. - @_disfavoredOverload - public func XCTFail(_ message: String = "") { - var message = message - attachHostApplicationWarningIfNeeded(&message) - guard - let currentTestCase = XCTCurrentTestCase, - let XCTIssue = NSClassFromString("XCTIssue") - as Any as? NSObjectProtocol, - let alloc = XCTIssue.perform(NSSelectorFromString("alloc"))? - .takeUnretainedValue(), - let issue = - alloc - .perform( - Selector(("initWithType:compactDescription:")), - with: 0, - with: message.isEmpty ? "failed" : message - )? - .takeUnretainedValue() - else { - if !_XCTIsTesting { - runtimeWarn(message) - } - return +#if canImport(ObjectiveC) + /// This function generates a failure immediately and unconditionally. + /// + /// Dynamically creates and records an `XCTIssue` under the hood that captures the source code + /// context of the caller. Useful for defining assertion helpers that fail in indirect code + /// paths, where the `file` and `line` of the failure have not been realized. + /// + /// - Parameter message: An optional description of the assertion, for inclusion in test + /// results. + @_disfavoredOverload + public func XCTFail(_ message: String = "") { + var message = message + attachHostApplicationWarningIfNeeded(&message) + guard + let currentTestCase = XCTCurrentTestCase, + let XCTIssue = NSClassFromString("XCTIssue") + as Any as? NSObjectProtocol, + let alloc = XCTIssue.perform(NSSelectorFromString("alloc"))? + .takeUnretainedValue(), + let issue = + alloc + .perform( + Selector(("initWithType:compactDescription:")), + with: 0, + with: message.isEmpty ? "failed" : message + )? + .takeUnretainedValue() + else { + if !_XCTIsTesting { + runtimeWarn(message) } - _ = currentTestCase.perform(Selector(("recordIssue:")), with: issue) + return } + _ = currentTestCase.perform(Selector(("recordIssue:")), with: issue) + } - /// This function generates a failure immediately and unconditionally. - /// - /// Dynamically calls `XCTFail` with the given file and line. Useful for defining assertion - /// helpers that have the source code context at hand and want to highlight the direct caller - /// of the helper. - /// - /// - Parameter message: An optional description of the assertion, for inclusion in test - /// results. - @_disfavoredOverload - public func XCTFail(_ message: String = "", file: StaticString, line: UInt) { - var message = message - attachHostApplicationWarningIfNeeded(&message) - _XCTFailureHandler(nil, true, "\(file)", line, "\(message.isEmpty ? "failed" : message)", nil) - } + /// This function generates a failure immediately and unconditionally. + /// + /// Dynamically calls `XCTFail` with the given file and line. Useful for defining assertion + /// helpers that have the source code context at hand and want to highlight the direct caller + /// of the helper. + /// + /// - Parameter message: An optional description of the assertion, for inclusion in test + /// results. + @_disfavoredOverload + public func XCTFail(_ message: String = "", file: StaticString, line: UInt) { + var message = message + attachHostApplicationWarningIfNeeded(&message) + _XCTFailureHandler(nil, true, "\(file)", line, "\(message.isEmpty ? "failed" : message)", nil) + } - private typealias XCTFailureHandler = @convention(c) ( - AnyObject?, Bool, UnsafePointer, UInt, String, String? - ) -> Void - private let _XCTFailureHandler = unsafeBitCast( - dlsym(dlopen(nil, RTLD_LAZY), "_XCTFailureHandler"), - to: XCTFailureHandler.self - ) + private typealias XCTFailureHandler = @convention(c) ( + AnyObject?, Bool, UnsafePointer, UInt, String, String? + ) -> Void + private let _XCTFailureHandler = unsafeBitCast( + dlsym(dlopen(nil, RTLD_LAZY), "_XCTFailureHandler"), + to: XCTFailureHandler.self + ) - private func attachHostApplicationWarningIfNeeded(_ message: inout String) { - guard - _XCTIsTesting, - Bundle.main.bundleIdentifier != "com.apple.dt.xctest.tool" - else { return } + private func attachHostApplicationWarningIfNeeded(_ message: inout String) { + guard + _XCTIsTesting, + Bundle.main.bundleIdentifier != "com.apple.dt.xctest.tool" + else { return } - let callStack = Thread.callStackSymbols + let callStack = Thread.callStackSymbols - // Detect when synchronous test exists in stack. - guard callStack.allSatisfy({ frame in !frame.contains(" XCTestCore ") }) - else { return } + // Detect when synchronous test exists in stack. + guard callStack.allSatisfy({ frame in !frame.contains(" XCTestCore ") }) + else { return } - // Detect when asynchronous test exists in stack. - guard callStack.allSatisfy({ frame in !isTestFrame(frame) }) - else { return } + // Detect when asynchronous test exists in stack. + guard callStack.allSatisfy({ frame in !isTestFrame(frame) }) + else { return } - if !message.contains(where: \.isNewline) { - message.append(" …") - } + if !message.contains(where: \.isNewline) { + message.append(" …") + } - message.append( - """ + message.append( + """ - ━━┉┅ - Note: This failure was emitted from tests running in a host application\ - \(Bundle.main.bundleIdentifier.map { " (\($0))" } ?? ""). + ━━┉┅ + Note: This failure was emitted from tests running in a host application\ + \(Bundle.main.bundleIdentifier.map { " (\($0))" } ?? ""). - This can lead to false positives, where failures could have emitted from live application \ - code at launch time, and not from the current test. + This can lead to false positives, where failures could have emitted from live application \ + code at launch time, and not from the current test. - For more information (and workarounds), see "Testing gotchas": + For more information (and workarounds), see "Testing gotchas": - https://pointfreeco.github.io/swift-dependencies/main/documentation/dependencies/testing#Testing-gotchas - """ - ) - } + https://pointfreeco.github.io/swift-dependencies/main/documentation/dependencies/testing#Testing-gotchas + """ + ) + } - func isTestFrame(_ frame: String) -> Bool { - // Regular expression to detect and demangle an XCTest case frame: - // - // 1. `(?<=\$s)`: Starts with "$s" (stable mangling) - // 2. `\d{1,3}`: Some numbers (the class name length or the module name length) - // 3. `.*`: The class name, or module name + class name length + class name - // 4. `C`: The class type identifier - // 5. `(?=\d{1,3}test.*yy(Ya)?K?F)`: The function name length, a function that starts with - // `test`, has no arguments (`y`), returns Void (`y`), and is a function (`F`), potentially - // async (`Ya`), throwing (`K`), or both. - let mangledTestFrame = #"(?<=\$s)\d{1,3}.*C(?=\d{1,3}test.*yy(Ya)?K?F)"# - - guard let XCTestCase = NSClassFromString("XCTestCase") - else { return false } - - return frame.range(of: mangledTestFrame, options: .regularExpression) - .map { - (_typeByName(String(frame[$0])) as? NSObject.Type)?.isSubclass(of: XCTestCase) ?? false - } - ?? false - } - #elseif canImport(XCTest) - // NB: It seems to be safe to import XCTest on Linux - @_exported import func XCTest.XCTFail - #else - @_disfavoredOverload - public func XCTFail(_ message: String = "") { - print(noop(message: message)) - } - @_disfavoredOverload - public func XCTFail(_ message: String = "", file: StaticString, line: UInt) { - print(noop(message: message, file: file, line: line)) - } - #endif + func isTestFrame(_ frame: String) -> Bool { + // Regular expression to detect and demangle an XCTest case frame: + // + // 1. `(?<=\$s)`: Starts with "$s" (stable mangling) + // 2. `\d{1,3}`: Some numbers (the class name length or the module name length) + // 3. `.*`: The class name, or module name + class name length + class name + // 4. `C`: The class type identifier + // 5. `(?=\d{1,3}test.*yy(Ya)?K?F)`: The function name length, a function that starts with + // `test`, has no arguments (`y`), returns Void (`y`), and is a function (`F`), potentially + // async (`Ya`), throwing (`K`), or both. + let mangledTestFrame = #"(?<=\$s)\d{1,3}.*C(?=\d{1,3}test.*yy(Ya)?K?F)"# + + guard let XCTestCase = NSClassFromString("XCTestCase") + else { return false } + + return frame.range(of: mangledTestFrame, options: .regularExpression) + .map { + (_typeByName(String(frame[$0])) as? NSObject.Type)?.isSubclass(of: XCTestCase) ?? false + } + ?? false + } +#elseif canImport(XCTest) + // NB: It seems to be safe to import XCTest on Linux + @_exported import func XCTest.XCTFail #else - /// This function generates a failure immediately and unconditionally. - /// - /// Dynamically creates and records an `XCTIssue` under the hood that captures the source code - /// context of the caller. Useful for defining assertion helpers that fail in indirect code - /// paths, where the `file` and `line` of the failure have not been realized. - /// - /// - Parameter message: An optional description of the assertion, for inclusion in test - /// results. @_disfavoredOverload public func XCTFail(_ message: String = "") { print(noop(message: message)) } - - /// This function generates a failure immediately and unconditionally. - /// - /// Dynamically creates and records an `XCTIssue` under the hood that captures the source code - /// context of the caller. Useful for defining assertion helpers that fail in indirect code - /// paths, where the `file` and `line` of the failure have not been realized. - /// - /// - Parameter message: An optional description of the assertion, for inclusion in test - /// results. @_disfavoredOverload public func XCTFail(_ message: String = "", file: StaticString, line: UInt) { print(noop(message: message, file: file, line: line)) diff --git a/Tests/XCTestDynamicOverlayTests/HostAppDetectionTests.swift b/Tests/XCTestDynamicOverlayTests/HostAppDetectionTests.swift index eb2de8d7..01e7fc1a 100644 --- a/Tests/XCTestDynamicOverlayTests/HostAppDetectionTests.swift +++ b/Tests/XCTestDynamicOverlayTests/HostAppDetectionTests.swift @@ -1,4 +1,4 @@ -#if DEBUG && canImport(ObjectiveC) +#if canImport(ObjectiveC) import XCTest @testable import XCTestDynamicOverlay