Skip to content

Commit

Permalink
Support for release builds
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
stephencelis committed Apr 13, 2023
1 parent 4af50b3 commit 1b38fe8
Show file tree
Hide file tree
Showing 4 changed files with 124 additions and 161 deletions.
3 changes: 0 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
42 changes: 18 additions & 24 deletions Sources/XCTestDynamicOverlay/Internal/XCTCurrentTestCase.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down
238 changes: 105 additions & 133 deletions Sources/XCTestDynamicOverlay/XCTFail.swift
Original file line number Diff line number Diff line change
@@ -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<CChar>, 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<CChar>, 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))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#if DEBUG && canImport(ObjectiveC)
#if canImport(ObjectiveC)
import XCTest

@testable import XCTestDynamicOverlay
Expand Down

0 comments on commit 1b38fe8

Please sign in to comment.