Skip to content

Commit ffa81a2

Browse files
committed
[SR-7615] Implement XCTWaiter and missing XCTestExpectation APIs
This brings Corelibs XCTest up-to-date with ObjC XCTest's modern asynchronous waiting APIs. This has been requested both via Radar (rdar://problem/41022833) and Swift JIRA ([SR-7615](https://bugs.swift.org/browse/SR-7615), [SR-6249](https://bugs.swift.org/browse/SR-6249)). It is effectively a rewrite of this project's asynchronous testing logic to match the behavior of Apple's ObjC framework. - Implement missing `XCTestExpectation` APIs including: - `expectedFulfillmentCount` - `isInverted` - `assertForOverFulfill` - Implement `XCTWaiter` class and `XCTWaiterDelegate` protocol - Publicly expose the `XCTNS(Predicate,Notification)Expectation` classes - Re-implement `XCTestCase.waitForExpectations(timeout:handler:)` to use XCTWaiter and add `XCTestCase.wait(for:timeout:)` - Implement "nested waiter" behavior, using a new internal helper class `WaiterManager` - Add documentation comments to all public APIs, based on their ObjC counterparts - Add a utility struct called `SourceLocation` which represents a file & line - Update expectation handler block typealiases to match current ObjC names - Match various API call syntaxes to their ObjC counterparts - Introduce a `subsystemQueue` DispatchQueue for synchronization, shared between XCTWaiter and all expectations - Modify XCTestCase's "un-waited" expectations failure message to note all un-waited on expectations, to match ObjC - Add many new tests
1 parent 033a5d1 commit ffa81a2

25 files changed

+2021
-419
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// This source file is part of the Swift.org open source project
2+
//
3+
// Copyright (c) 2018 Apple Inc. and the Swift project authors
4+
// Licensed under Apache License v2.0 with Runtime Library Exception
5+
//
6+
// See http://swift.org/LICENSE.txt for license information
7+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
8+
//
9+
//
10+
// SourceLocation.swift
11+
//
12+
13+
internal struct SourceLocation {
14+
15+
typealias LineNumber = UInt
16+
17+
/// Represents an "unknown" source location, with default values, which may be used as a fallback
18+
/// when a real source location may not be known.
19+
static var unknown: SourceLocation = {
20+
return SourceLocation(file: "<unknown>", line: 0)
21+
}()
22+
23+
let file: String
24+
let line: LineNumber
25+
26+
init(file: String, line: LineNumber) {
27+
self.file = file
28+
self.line = line
29+
}
30+
31+
init(file: StaticString, line: LineNumber) {
32+
self.init(file: String(describing: file), line: line)
33+
}
34+
35+
init(file: String, line: Int) {
36+
self.init(file: file, line: LineNumber(line))
37+
}
38+
39+
init(file: StaticString, line: Int) {
40+
self.init(file: String(describing: file), line: LineNumber(line))
41+
}
42+
43+
}
+145
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// This source file is part of the Swift.org open source project
2+
//
3+
// Copyright (c) 2018 Apple Inc. and the Swift project authors
4+
// Licensed under Apache License v2.0 with Runtime Library Exception
5+
//
6+
// See http://swift.org/LICENSE.txt for license information
7+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
8+
//
9+
//
10+
// WaiterManager.swift
11+
//
12+
13+
internal protocol ManageableWaiter: AnyObject, Equatable {
14+
var isFinished: Bool { get }
15+
16+
// Invoked on `XCTWaiter.subsystemQueue`
17+
func queue_handleWatchdogTimeout()
18+
func queue_interrupt(for interruptingWaiter: Self)
19+
}
20+
21+
private protocol ManageableWaiterWatchdog {
22+
func cancel()
23+
}
24+
extension DispatchWorkItem: ManageableWaiterWatchdog {}
25+
26+
/// This class manages the XCTWaiter instances which are currently waiting on a particular thread.
27+
/// It facilitates "nested" waiters, allowing an outer waiter to interrupt inner waiters if it times
28+
/// out.
29+
internal final class WaiterManager<WaiterType: ManageableWaiter> {
30+
31+
/// The current thread's waiter manager. This is the only supported way to access an instance of
32+
/// this class, since each instance is bound to a particular thread and is only concerned with
33+
/// the XCTWaiters waiting on that thread.
34+
static var current: WaiterManager {
35+
let threadKey = "org.swift.XCTest.WaiterManager"
36+
37+
if let existing = Thread.current.threadDictionary[threadKey] as? WaiterManager {
38+
return existing
39+
} else {
40+
let manager = WaiterManager()
41+
Thread.current.threadDictionary[threadKey] = manager
42+
return manager
43+
}
44+
}
45+
46+
private struct ManagedWaiterDetails {
47+
let waiter: WaiterType
48+
let watchdog: ManageableWaiterWatchdog?
49+
}
50+
51+
private var managedWaiterStack = [ManagedWaiterDetails]()
52+
private weak var thread = Thread.current
53+
private let queue = DispatchQueue(label: "org.swift.XCTest.WaiterManager")
54+
55+
// Use `WaiterManager.current` to access the thread-specific instance
56+
private init() {}
57+
58+
deinit {
59+
assert(managedWaiterStack.isEmpty, "Waiters still registered when WaiterManager is deallocating.")
60+
}
61+
62+
func startManaging(_ waiter: WaiterType, timeout: TimeInterval) {
63+
guard let thread = thread else { fatalError("\(self) no longer belongs to a thread") }
64+
precondition(thread === Thread.current, "\(#function) called on wrong thread, must be called on \(thread)")
65+
66+
var alreadyFinishedOuterWaiter: WaiterType?
67+
68+
queue.sync {
69+
// To start managing `waiter`, first see if any existing, outer waiters have already finished,
70+
// because if one has, then `waiter` will be immediately interrupted before it begins waiting.
71+
alreadyFinishedOuterWaiter = managedWaiterStack.first(where: { $0.waiter.isFinished })?.waiter
72+
73+
let watchdog: ManageableWaiterWatchdog?
74+
if alreadyFinishedOuterWaiter == nil {
75+
// If there is no already-finished outer waiter, install a watchdog for `waiter`, and store it
76+
// alongside `waiter` so that it may be canceled if `waiter` finishes waiting within its allotted timeout.
77+
watchdog = WaiterManager.installWatchdog(for: waiter, timeout: timeout)
78+
} else {
79+
// If there is an already-finished outer waiter, no watchdog is needed for `waiter` because it will
80+
// be interrupted before it begins waiting.
81+
watchdog = nil
82+
}
83+
84+
// Add the waiter even if it's going to immediately be interrupted below to simplify the stack management
85+
let details = ManagedWaiterDetails(waiter: waiter, watchdog: watchdog)
86+
managedWaiterStack.append(details)
87+
}
88+
89+
if let alreadyFinishedOuterWaiter = alreadyFinishedOuterWaiter {
90+
XCTWaiter.subsystemQueue.async {
91+
waiter.queue_interrupt(for: alreadyFinishedOuterWaiter)
92+
}
93+
}
94+
}
95+
96+
func stopManaging(_ waiter: WaiterType) {
97+
guard let thread = thread else { fatalError("\(self) no longer belongs to a thread") }
98+
precondition(thread === Thread.current, "\(#function) called on wrong thread, must be called on \(thread)")
99+
100+
queue.sync {
101+
precondition(!managedWaiterStack.isEmpty, "Waiter stack was empty when requesting to stop managing: \(waiter)")
102+
103+
let expectedIndex = managedWaiterStack.index(before: managedWaiterStack.endIndex)
104+
let waiterDetails = managedWaiterStack[expectedIndex]
105+
guard waiter == waiterDetails.waiter else {
106+
fatalError("Top waiter on stack \(waiterDetails.waiter) is not equal to waiter to stop managing: \(waiter)")
107+
}
108+
109+
waiterDetails.watchdog?.cancel()
110+
managedWaiterStack.remove(at: expectedIndex)
111+
}
112+
}
113+
114+
private static func installWatchdog(for waiter: WaiterType, timeout: TimeInterval) -> ManageableWaiterWatchdog {
115+
// Use DispatchWorkItem instead of a basic closure since it can be canceled.
116+
let watchdog = DispatchWorkItem { [weak waiter] in
117+
waiter?.queue_handleWatchdogTimeout()
118+
}
119+
120+
let outerTimeoutSlop = TimeInterval(0.25)
121+
let deadline = DispatchTime.now() + timeout + outerTimeoutSlop
122+
XCTWaiter.subsystemQueue.asyncAfter(deadline: deadline, execute: watchdog)
123+
124+
return watchdog
125+
}
126+
127+
func queue_handleWatchdogTimeout(of waiter: WaiterType) {
128+
dispatchPrecondition(condition: .onQueue(XCTWaiter.subsystemQueue))
129+
130+
var waitersToInterrupt = [WaiterType]()
131+
132+
queue.sync {
133+
guard let indexOfWaiter = managedWaiterStack.firstIndex(where: { $0.waiter == waiter }) else {
134+
preconditionFailure("Waiter \(waiter) reported timed out but is not in the waiter stack \(managedWaiterStack)")
135+
}
136+
137+
waitersToInterrupt += managedWaiterStack[managedWaiterStack.index(after: indexOfWaiter)...].map { $0.waiter }
138+
}
139+
140+
for waiterToInterrupt in waitersToInterrupt.reversed() {
141+
waiterToInterrupt.queue_interrupt(for: waiter)
142+
}
143+
}
144+
145+
}

Sources/XCTest/Private/XCPredicateExpectation.swift

-52
This file was deleted.

Sources/XCTest/Public/Asynchronous/XCNotificationExpectationHandler.swift

-20
This file was deleted.

Sources/XCTest/Public/Asynchronous/XCPredicateExpectationHandler.swift

-19
This file was deleted.

0 commit comments

Comments
 (0)