1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127 | import Foundation
import Testing
@testable import IrdAPI
/// Scripted HTTP: a handler answers each request; every request is recorded.
final class FakeHTTP: HTTPTransport, @unchecked Sendable {
private let lock = NSLock()
private(set) var requests: [URLRequest] = []
private var queue: [HTTPResponse] = []
private var handler: (@Sendable (URLRequest) async throws -> HTTPResponse)?
init(_ handler: @escaping @Sendable (URLRequest) async throws -> HTTPResponse = { _ in json(200, "{}") }) {
self.handler = handler
}
/// Replies consumed in order; the last one repeats.
init(script: [HTTPResponse]) { queue = script }
func send(_ request: URLRequest) async throws -> HTTPResponse {
let scripted: HTTPResponse? = lock.withLock {
requests.append(request)
guard !queue.isEmpty else { return nil }
return queue.count == 1 ? queue[0] : queue.removeFirst()
}
if let scripted { return scripted }
guard let handler else { return json(200, "{}") }
return try await handler(request)
}
var count: Int { lock.withLock { requests.count } }
var last: URLRequest? { lock.withLock { requests.last } }
/// `["GET /a", "POST /b?x=1"]` — method plus path and query of every request.
var trail: [String] { lock.withLock { requests.map { "\($0.httpMethod ?? "?") \(pathAndQuery($0))" } } }
}
func pathAndQuery(_ r: URLRequest) -> String {
guard let url = r.url else { return "?" }
var s = url.path
if let q = url.query { s += "?" + q }
return s
}
func json(_ status: Int, _ body: String) -> HTTPResponse { HTTPResponse(status: status, body: Data(body.utf8)) }
/// The JSON body a request carries, if any.
func bodyJSON(_ r: URLRequest?) -> JSONValue? {
guard let data = r?.httpBody else { return nil }
return try? JSONValue.parse(data)
}
/// A client on a fake transport with an instant clock.
func captureClient(apiKey: String? = "ird_k", script: [HTTPResponse] = [json(200, "{}")],
clock: any IrdClock = InstantClock(), headers: [String: String] = [:]) -> (IrdClient, FakeHTTP) {
let http = FakeHTTP(script: script)
var c = Configuration()
if let apiKey { c.credential = StaticCredential(apiKey) }
c.transport = http
c.clock = clock
c.headers = headers
return (IrdClient(configuration: c), http)
}
/// Never sleeps; time stands still.
struct InstantClock: IrdClock {
var now: Duration { .zero }
func sleep(for duration: Duration) async throws {}
}
/// A manually advanced clock. Sleepers wake when `advance` moves time past their
/// deadline, and a cancelled sleeper is removed (like `Task.sleep`, it throws).
final class TestClock: IrdClock, @unchecked Sendable {
private let lock = NSLock()
private var time: Duration = .zero
private var waiters: [(id: UUID, deadline: Duration, c: CheckedContinuation<Void, any Error>)] = []
private(set) var sleeps: [Duration] = []
var now: Duration { lock.withLock { time } }
func sleep(for duration: Duration) async throws {
let id = UUID()
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, any Error>) in
let immediate: Bool = lock.withLock {
sleeps.append(duration)
if duration <= .zero { return true }
waiters.append((id, time + duration, c))
return false
}
if immediate { c.resume() }
}
} onCancel: {
let cancelled: CheckedContinuation<Void, any Error>? = lock.withLock {
guard let i = waiters.firstIndex(where: { $0.id == id }) else { return nil }
return waiters.remove(at: i).c
}
cancelled?.resume(throwing: CancellationError())
}
}
func advance(by d: Duration) {
let due: [CheckedContinuation<Void, any Error>] = lock.withLock {
time += d
let fired = waiters.filter { $0.deadline <= time }.map(\.c)
waiters.removeAll { $0.deadline <= time }
return fired
}
for c in due { c.resume() }
}
var sleeperCount: Int { lock.withLock { waiters.count } }
var recordedSleeps: [Duration] { lock.withLock { sleeps } }
}
/// Runs `body` and returns the `IrdError` it threw, or nil.
func caught(_ body: () async throws -> Void) async -> IrdError? {
do { try await body(); return nil } catch let e as IrdError { return e } catch { return nil }
}
/// Polls until `condition` holds or `timeout` (real time) elapses.
func eventually(timeout: Duration = .seconds(3), _ condition: @Sendable () async -> Bool) async -> Bool {
let deadline = ContinuousClock.now + timeout
while ContinuousClock.now < deadline {
if await condition() { return true }
try? await Task.sleep(for: .milliseconds(5))
}
return await condition()
}
|