Skip to content

Commit

Permalink
Finesse host application warning (#49)
Browse files Browse the repository at this point in the history
* Finesse host application warning

Just a lil cleanup work from #48.

* wip

* wip

* wip

* wip
  • Loading branch information
stephencelis authored Feb 7, 2023
1 parent cdcc63c commit ace2130
Show file tree
Hide file tree
Showing 2 changed files with 96 additions and 118 deletions.
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

0 comments on commit ace2130

Please sign in to comment.