Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 79 additions & 81 deletions Sources/XCTestDynamicOverlay/XCTFail.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#if DEBUG
import Foundation
import Foundation

#if DEBUG
#if canImport(ObjectiveC)
/// This function generates a failure immediately and unconditionally.
///
Expand All @@ -12,7 +12,8 @@
/// results.
@_disfavoredOverload
public func XCTFail(_ message: String = "") {
let message = appendHostAppWarningIfNeeded(message)
var message = message
attachHostApplicationWarningIfNeeded(&message)
guard
let currentTestCase = XCTCurrentTestCase,
let XCTIssue = NSClassFromString("XCTIssue")
Expand Down Expand Up @@ -46,7 +47,8 @@
/// results.
@_disfavoredOverload
public func XCTFail(_ message: String = "", file: StaticString, line: UInt) {
let message = appendHostAppWarningIfNeeded(message)
var message = message
attachHostApplicationWarningIfNeeded(&message)
_XCTFailureHandler(nil, true, "\(file)", line, "\(message.isEmpty ? "failed" : message)", nil)
}

Expand All @@ -57,6 +59,79 @@
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 }

let callStack = Thread.callStackSymbols

// 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 }

let displayName =
Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
?? Bundle.main.object(forInfoDictionaryKey: kCFBundleNameKey as String) as? String
?? "Unknown host application"

let bundleIdentifier = Bundle.main.bundleIdentifier ?? "Unknown bundle identifier"

if !message.contains(where: \.isNewline) {
message.append(" …")
}

message.append("""


┏━━━━━━━━━━━━━━━━━┉┅
┃ ⚠︎ Warning:
┃ This failure was emitted from a host application outside the test stack.
┃ Host application:
┃ \(displayName) (\(bundleIdentifier))
┃ The host application may have emitted this failure when it first launched,
┃ outside this current test that happens to be running.
┃ Consider setting the test target's host application to "None," or prevent
┃ the host application from performing the code path that emits failure.
┗━━━━━━━━━━━━━━━━━┉┅
▀▄▀▄▀▄▀▄▀▄▀▄▀▄▀▄▀▄▀▄▀▄▀▄▀▄▀▄▀▄▀▄▀▄

For more information (and workarounds), see "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
Expand All @@ -66,83 +141,6 @@
@_disfavoredOverload
public func XCTFail(_ message: String = "", file: StaticString, line: UInt) {}
#endif

func appendHostAppWarningIfNeeded(_ originalMessage: String) -> String {
guard _XCTIsTesting else { return originalMessage }
if Bundle.main.bundleIdentifier == "com.apple.dt.xctest.tool" // Apple platforms
|| Bundle.main.bundleIdentifier == nil // Linux
{
// XCTesting is providing a default host app.
return originalMessage
}

if Thread.callStackSymbols.contains(where: { $0.range(of: "XCTestCore") != nil }) {
// We are apparently performing a sync test
return originalMessage
}

if testCaseSubclass(callStackSymbols: Thread.callStackSymbols) != nil {
// We are apparently performing an async test.
// We're matching a `() -> ()` function that starts with `test`, from a `XCTestCase` subclass
return originalMessage
}

let message = """
Warning! This failure occurred while running tests hosted by the main app.

Testing using the main app as a host can lead to false positive test failures created by the \
app accessing unimplemented values itself when it is spun up.

- Test host: \(Bundle.main.bundleIdentifier ?? "Unknown")

You can find more information and workarounds in the "Testing/Testing Gotchas" section of \
Dependencies' documentation at \
https://pointfreeco.github.io/swift-dependencies/main/documentation/dependencies/testing/.
"""

return [originalMessage, "", message].joined(separator: "\n")
}

// (?<=\$s): Starts with "$s" (stable mangling);
// \d{1,3}: Some numbers (the class name length or the module name length);
// .*: The class name, or module name + class name length + class name;
// C: The class type identifier;
// (?=\d{1,3}test.*yy(Ya)?K?F): Followed by the function name length, function that starts with
// `test`, has no arguments (y), returns Void (y), and is a function (F), potentially async (Ya),
// throwing (K), or both.
private let testCaseRegex = #"(?<=\$s)\d{1,3}.*C(?=\d{1,3}test.*yy(Ya)?K?F)"#

func testCaseSubclass(callStackSymbols: [String]) -> Any.Type? {
for frame in callStackSymbols {
var startIndex = frame.startIndex
while startIndex != frame.endIndex {
if let range = frame.range(
of: testCaseRegex,
options: .regularExpression,
range: startIndex..<frame.endIndex,
locale: nil
) {
if let testCase = testCase(mangledName: String(frame[range])) {
return testCase
}
startIndex = range.upperBound
} else {
break
}
}
}
return nil
}

private func testCase(mangledName: String) -> Any.Type? {
if let object = _typeByName(mangledName) as? NSObject.Type,
NSClassFromString("XCTestCase").map(object.isSubclass(of:)) == true
{
return object
}
return nil
}

#else
/// This function generates a failure immediately and unconditionally.
///
Expand Down
54 changes: 17 additions & 37 deletions Tests/XCTestDynamicOverlayTests/HostAppDetectionTests.swift
Original file line number Diff line number Diff line change
@@ -1,43 +1,23 @@
import XCTest
#if DEBUG && canImport(ObjectiveC)
import XCTest

@testable import XCTestDynamicOverlay
@testable import XCTestDynamicOverlay

final class HostAppCallStackTests: XCTestCase {
func testIsAbleToDetectTest() {
XCTAssertEqual(
testCaseSubclass(callStackSymbols: Thread.callStackSymbols).map(ObjectIdentifier.init),
ObjectIdentifier(HostAppCallStackTests.self)
)
}

func testIsAbleToDetectAsyncTest() async {
XCTAssertEqual(
testCaseSubclass(callStackSymbols: Thread.callStackSymbols).map(ObjectIdentifier.init),
ObjectIdentifier(HostAppCallStackTests.self)
)
}
final class HostAppCallStackTests: XCTestCase {
func testIsAbleToDetectTest() {
XCTAssert(Thread.callStackSymbols.contains(where: isTestFrame))
}

func testIsAbleToDetectThrowingTest() throws {
XCTAssertEqual(
testCaseSubclass(callStackSymbols: Thread.callStackSymbols).map(ObjectIdentifier.init),
ObjectIdentifier(HostAppCallStackTests.self)
)
}
func testIsAbleToDetectAsyncTest() async {
XCTAssert(Thread.callStackSymbols.contains(where: isTestFrame))
}

func testIsAbleToDetectAsyncThrowingTest() async throws {
XCTAssertEqual(
testCaseSubclass(callStackSymbols: Thread.callStackSymbols).map(ObjectIdentifier.init),
ObjectIdentifier(HostAppCallStackTests.self)
)
}
func testIsAbleToDetectThrowingTest() throws {
XCTAssert(Thread.callStackSymbols.contains(where: isTestFrame))
}

#if !os(Linux)
func testFailDoesNotAppendHostAppWarningFromATest() {
XCTExpectFailure {
XCTestDynamicOverlay.XCTFail("foo")
} issueMatcher: {
$0.compactDescription == "foo"
}
func testIsAbleToDetectAsyncThrowingTest() async throws {
XCTAssert(Thread.callStackSymbols.contains(where: isTestFrame))
}
#endif
}
}
#endif