feat(ios): W0 scaffold + day-1 spike + WireProtocol frozen contract + TestSupport doubles
T-iOS-1: ios/ XcodeGen project (iOS 17, Swift 6 strict concurrency, ATS per PLAN §5.2), 5 SPM package shells, CI skeleton T-iOS-2: Origin spike vs real server — URLSessionWebSocketTask custom Origin CONFIRMED (no Starscream); 16MiB replay + EMSGSIZE(40) errno correction written back to plan T-iOS-3: WireProtocol frozen contract, 59 tests, 100% line coverage, cross-impl vectors vs src/protocol.ts via tsx T-iOS-4: FakeTransport/FakeClock/FakeHTTPTransport doubles Verify: independent agent re-ran all acceptance — 6/6 PASS
This commit is contained in:
176
ios/Packages/TestSupport/Sources/TestSupport/FakeClock.swift
Normal file
176
ios/Packages/TestSupport/Sources/TestSupport/FakeClock.swift
Normal file
@@ -0,0 +1,176 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Manually advanced `Clock<Duration>` double (T-iOS-4). Drives
|
||||
/// `ReconnectMachine` / `PingScheduler` / `SessionEngine` tests with ZERO
|
||||
/// real-time waiting: code under test suspends in `sleep(until:tolerance:)`
|
||||
/// and only resumes when the test calls `advance(by:)` past its deadline.
|
||||
///
|
||||
/// Deterministic hand-off pattern (no yield-loops, no real sleeps):
|
||||
///
|
||||
/// let task = Task { try await scheduler.run() } // sleeps on this clock
|
||||
/// await clock.waitForSleepers(count: 1) // sleeper is parked
|
||||
/// clock.advance(by: Tunables.pingInterval) // fire it
|
||||
///
|
||||
/// Sendable: all state lives behind one `OSAllocatedUnfairLock`; continuations
|
||||
/// are always resumed OUTSIDE the lock. Cancelling a sleeping task throws
|
||||
/// `CancellationError` out of `sleep`, matching `ContinuousClock` semantics.
|
||||
public final class FakeClock: Clock, Sendable {
|
||||
/// Instant = duration offset from the clock's zero epoch.
|
||||
public struct Instant: InstantProtocol, Sendable, Hashable, Comparable {
|
||||
public typealias Duration = Swift.Duration
|
||||
|
||||
public let offset: Swift.Duration
|
||||
|
||||
public init(offset: Swift.Duration = .zero) {
|
||||
self.offset = offset
|
||||
}
|
||||
|
||||
public func advanced(by duration: Swift.Duration) -> Instant {
|
||||
Instant(offset: offset + duration)
|
||||
}
|
||||
|
||||
public func duration(to other: Instant) -> Swift.Duration {
|
||||
other.offset - offset
|
||||
}
|
||||
|
||||
public static func < (lhs: Instant, rhs: Instant) -> Bool {
|
||||
lhs.offset < rhs.offset
|
||||
}
|
||||
}
|
||||
|
||||
private struct Sleeper {
|
||||
let deadline: Instant
|
||||
let continuation: CheckedContinuation<Void, any Error>
|
||||
}
|
||||
|
||||
private struct Waiter {
|
||||
let targetCount: Int
|
||||
let continuation: CheckedContinuation<Void, Never>
|
||||
}
|
||||
|
||||
private struct State {
|
||||
var now: Instant
|
||||
var sleepers: [UUID: Sleeper] = [:]
|
||||
var waiters: [UUID: Waiter] = [:]
|
||||
}
|
||||
|
||||
private enum SleepRegistration {
|
||||
case alreadyDue
|
||||
case cancelled
|
||||
case sleeping(satisfiedWaiters: [Waiter])
|
||||
}
|
||||
|
||||
private let state: OSAllocatedUnfairLock<State>
|
||||
|
||||
public init(now: Instant = Instant()) {
|
||||
state = OSAllocatedUnfairLock(initialState: State(now: now))
|
||||
}
|
||||
|
||||
// MARK: - Clock conformance
|
||||
|
||||
public var now: Instant {
|
||||
state.withLock { $0.now }
|
||||
}
|
||||
|
||||
public var minimumResolution: Swift.Duration { .zero }
|
||||
|
||||
public func sleep(until deadline: Instant, tolerance: Swift.Duration? = nil) async throws {
|
||||
let id = UUID()
|
||||
try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
|
||||
registerSleeper(id: id, deadline: deadline, continuation: continuation)
|
||||
}
|
||||
} onCancel: {
|
||||
let sleeper = state.withLock { $0.sleepers.removeValue(forKey: id) }
|
||||
sleeper?.continuation.resume(throwing: CancellationError())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Manual control (test side)
|
||||
|
||||
/// Move time forward and synchronously resume every sleeper whose deadline
|
||||
/// is now due (in deadline order) — resumed outside the lock.
|
||||
public func advance(by duration: Swift.Duration) {
|
||||
precondition(duration >= .zero, "FakeClock cannot move backwards")
|
||||
let due: [Sleeper] = state.withLock { state in
|
||||
state.now = state.now.advanced(by: duration)
|
||||
let dueNow = state.sleepers.filter { $0.value.deadline <= state.now }
|
||||
for key in dueNow.keys {
|
||||
state.sleepers.removeValue(forKey: key)
|
||||
}
|
||||
return dueNow.values.sorted { $0.deadline < $1.deadline }
|
||||
}
|
||||
for sleeper in due {
|
||||
sleeper.continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of tasks currently parked in `sleep` — for test assertions.
|
||||
public var pendingSleeperCount: Int {
|
||||
state.withLock { $0.sleepers.count }
|
||||
}
|
||||
|
||||
/// Suspend until at least `count` sleepers are parked. This is the
|
||||
/// deterministic "task reached its sleep" barrier — never poll or
|
||||
/// real-sleep to wait for the code under test. Resumes immediately if
|
||||
/// already satisfied; on task cancellation it resumes without error.
|
||||
public func waitForSleepers(count: Int = 1) async {
|
||||
let id = UUID()
|
||||
await withTaskCancellationHandler {
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
registerWaiter(id: id, targetCount: count, continuation: continuation)
|
||||
}
|
||||
} onCancel: {
|
||||
let waiter = state.withLock { $0.waiters.removeValue(forKey: id) }
|
||||
waiter?.continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals (registration under the lock, resumption outside it)
|
||||
|
||||
private func registerSleeper(
|
||||
id: UUID,
|
||||
deadline: Instant,
|
||||
continuation: CheckedContinuation<Void, any Error>
|
||||
) {
|
||||
let registration: SleepRegistration = state.withLock { state in
|
||||
if Task.isCancelled { return .cancelled }
|
||||
guard deadline > state.now else { return .alreadyDue }
|
||||
state.sleepers[id] = Sleeper(deadline: deadline, continuation: continuation)
|
||||
return .sleeping(satisfiedWaiters: Self.takeSatisfiedWaiters(&state))
|
||||
}
|
||||
switch registration {
|
||||
case .alreadyDue:
|
||||
continuation.resume()
|
||||
case .cancelled:
|
||||
continuation.resume(throwing: CancellationError())
|
||||
case .sleeping(let satisfiedWaiters):
|
||||
for waiter in satisfiedWaiters {
|
||||
waiter.continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func registerWaiter(
|
||||
id: UUID,
|
||||
targetCount: Int,
|
||||
continuation: CheckedContinuation<Void, Never>
|
||||
) {
|
||||
let isAlreadySatisfied: Bool = state.withLock { state in
|
||||
if Task.isCancelled || state.sleepers.count >= targetCount { return true }
|
||||
state.waiters[id] = Waiter(targetCount: targetCount, continuation: continuation)
|
||||
return false
|
||||
}
|
||||
if isAlreadySatisfied {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private static func takeSatisfiedWaiters(_ state: inout State) -> [Waiter] {
|
||||
let satisfiedKeys = state.waiters
|
||||
.filter { $0.value.targetCount <= state.sleepers.count }
|
||||
.map { $0.key }
|
||||
return satisfiedKeys.compactMap { state.waiters.removeValue(forKey: $0) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
/// Errors `FakeHTTPTransport` produces on its own (Equatable for exact
|
||||
/// `#expect(throws:)` assertions).
|
||||
public enum FakeHTTPTransportError: Error, Equatable, Sendable {
|
||||
/// `send` was called for a URL/method with no queued response — the test
|
||||
/// forgot to script it. Loud and identifying, never a silent hang.
|
||||
case noQueuedResponse(method: String, url: URL)
|
||||
/// The request had no URL at all (malformed test input).
|
||||
case requestMissingURL
|
||||
/// `HTTPURLResponse` refused the scripted status/headers (should not
|
||||
/// happen with sane inputs; surfaced explicitly rather than force-unwrapped).
|
||||
case responseConstructionFailed(url: URL)
|
||||
}
|
||||
|
||||
/// In-memory `HTTPTransport` double (T-iOS-4). `APIClient` cannot tell it
|
||||
/// apart from the `URLSession`-backed implementation (plan §3.4).
|
||||
///
|
||||
/// - **Queue responses per URL/method** (FIFO per route): `queueSuccess` /
|
||||
/// `queueFailure`. An unqueued route throws `.noQueuedResponse` immediately.
|
||||
/// - **Record requests verbatim, headers included** — so Origin-iff-G tests
|
||||
/// (plan §3.4 铁律) can assert exactly which requests carried `Origin`.
|
||||
public actor FakeHTTPTransport: HTTPTransport {
|
||||
/// Route identity: HTTP method (uppercased) + exact URL.
|
||||
public struct RouteKey: Hashable, Sendable {
|
||||
public let method: String
|
||||
public let url: URL
|
||||
|
||||
public init(method: String, url: URL) {
|
||||
self.method = method.uppercased()
|
||||
self.url = url
|
||||
}
|
||||
}
|
||||
|
||||
private enum QueuedResult {
|
||||
case success(status: Int, headers: [String: String], body: Data)
|
||||
case failure(any Error)
|
||||
}
|
||||
|
||||
/// Default HTTP method for queueing/matching when a request omits one.
|
||||
public static let defaultMethod = "GET"
|
||||
/// Default scripted success status.
|
||||
public static let defaultOKStatus = 200
|
||||
private static let httpVersion = "HTTP/1.1"
|
||||
|
||||
private var queues: [RouteKey: [QueuedResult]] = [:]
|
||||
|
||||
/// Every request passed to `send`, in order, verbatim (URL, method,
|
||||
/// headers, body) — including ones that found no queued response.
|
||||
public private(set) var recordedRequests: [URLRequest] = []
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - Scripting
|
||||
|
||||
/// Queue one successful response for `method url` (FIFO per route).
|
||||
public func queueSuccess(
|
||||
method: String = FakeHTTPTransport.defaultMethod,
|
||||
url: URL,
|
||||
status: Int = FakeHTTPTransport.defaultOKStatus,
|
||||
headers: [String: String] = [:],
|
||||
body: Data = Data()
|
||||
) {
|
||||
enqueue(.success(status: status, headers: headers, body: body),
|
||||
for: RouteKey(method: method, url: url))
|
||||
}
|
||||
|
||||
/// Queue one transport-level failure for `method url` (e.g. connection
|
||||
/// refused → `PairingError.hostUnreachable` classification tests).
|
||||
public func queueFailure(
|
||||
method: String = FakeHTTPTransport.defaultMethod,
|
||||
url: URL,
|
||||
error: any Error
|
||||
) {
|
||||
enqueue(.failure(error), for: RouteKey(method: method, url: url))
|
||||
}
|
||||
|
||||
// MARK: - HTTPTransport
|
||||
|
||||
public func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
recordedRequests.append(request)
|
||||
guard let url = request.url else {
|
||||
throw FakeHTTPTransportError.requestMissingURL
|
||||
}
|
||||
let key = RouteKey(method: request.httpMethod ?? Self.defaultMethod, url: url)
|
||||
guard var queue = queues[key], !queue.isEmpty else {
|
||||
throw FakeHTTPTransportError.noQueuedResponse(method: key.method, url: url)
|
||||
}
|
||||
let next = queue.removeFirst()
|
||||
queues[key] = queue
|
||||
switch next {
|
||||
case .failure(let error):
|
||||
throw error
|
||||
case .success(let status, let headers, let body):
|
||||
return (body, try Self.makeResponse(url: url, status: status, headers: headers))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private func enqueue(_ result: QueuedResult, for key: RouteKey) {
|
||||
queues[key, default: []].append(result)
|
||||
}
|
||||
|
||||
private static func makeResponse(
|
||||
url: URL,
|
||||
status: Int,
|
||||
headers: [String: String]
|
||||
) throws -> HTTPURLResponse {
|
||||
guard let response = HTTPURLResponse(
|
||||
url: url, statusCode: status, httpVersion: httpVersion, headerFields: headers
|
||||
) else {
|
||||
throw FakeHTTPTransportError.responseConstructionFailed(url: url)
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
161
ios/Packages/TestSupport/Sources/TestSupport/FakeTransport.swift
Normal file
161
ios/Packages/TestSupport/Sources/TestSupport/FakeTransport.swift
Normal file
@@ -0,0 +1,161 @@
|
||||
import WireProtocol
|
||||
|
||||
/// Errors `FakeTransport` produces on its own (Equatable so tests can
|
||||
/// `#expect(throws:)` on exact values).
|
||||
public enum FakeTransportError: Error, Equatable, Sendable {
|
||||
/// Default error thrown by a scripted connect failure.
|
||||
case scriptedConnectFailure
|
||||
/// `send` was called on a connection that already terminated
|
||||
/// (client `close()`, server finish, or server error).
|
||||
case sendAfterClose
|
||||
}
|
||||
|
||||
/// In-memory `TermTransport` double (T-iOS-4). `SessionEngine` cannot tell it
|
||||
/// apart from `URLSessionTermTransport` — same frozen contract (plan §3.2).
|
||||
///
|
||||
/// Capabilities:
|
||||
/// - **Scripted connect failures**: `scriptConnectFailure(_:)` queues errors
|
||||
/// thrown by subsequent `connect` calls, FIFO (reconnect/backoff tests).
|
||||
/// - **Manual frame injection**: `emit(frame:)` / `emitError(_:)` /
|
||||
/// `finishFrames()` drive the latest connection's `frames` stream. With no
|
||||
/// live connection the event is queued and flushed, in order, into the next
|
||||
/// successful `connect` — so tests can script server behavior up front
|
||||
/// (mirrors attach→replay) and nothing is ever silently dropped.
|
||||
/// - **Recording**: every connect attempt (including scripted failures), every
|
||||
/// sent frame (per connection and flattened), and every `close()` call.
|
||||
///
|
||||
/// Async-safe by construction (actor), Sendable, zero real-time sleeps:
|
||||
/// injected frames buffer unboundedly until the consumer iterates.
|
||||
public actor FakeTransport: TermTransport {
|
||||
private enum ServerEvent {
|
||||
case frame(String)
|
||||
case failure(any Error)
|
||||
case finish
|
||||
}
|
||||
|
||||
private struct ConnectionState {
|
||||
let continuation: AsyncThrowingStream<String, any Error>.Continuation
|
||||
var sentFrames: [String] = []
|
||||
var isTerminated = false
|
||||
}
|
||||
|
||||
private var scriptedConnectFailures: [any Error] = []
|
||||
private var pendingEvents: [ServerEvent] = []
|
||||
private var connections: [ConnectionState] = []
|
||||
|
||||
/// Every endpoint `connect` was called with, in order — scripted failures
|
||||
/// included (so backoff tests can count attempts).
|
||||
public private(set) var connectAttempts: [HostEndpoint] = []
|
||||
/// Total `close()` calls across all connections (double-close included).
|
||||
public private(set) var closeCallCount = 0
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - Scripting
|
||||
|
||||
/// Queue an error for the next `connect` call (FIFO across calls).
|
||||
public func scriptConnectFailure(
|
||||
_ error: any Error = FakeTransportError.scriptedConnectFailure
|
||||
) {
|
||||
scriptedConnectFailures.append(error)
|
||||
}
|
||||
|
||||
// MARK: - TermTransport
|
||||
|
||||
public func connect(to endpoint: HostEndpoint) async throws -> TransportConnection {
|
||||
connectAttempts.append(endpoint)
|
||||
if !scriptedConnectFailures.isEmpty {
|
||||
throw scriptedConnectFailures.removeFirst()
|
||||
}
|
||||
let (stream, continuation) = AsyncThrowingStream<String, any Error>.makeStream()
|
||||
let index = connections.count
|
||||
connections.append(ConnectionState(continuation: continuation))
|
||||
flushPendingEvents(into: index)
|
||||
return TransportConnection(
|
||||
frames: stream,
|
||||
send: { [self] frame in try await self.recordSend(frame, connection: index) },
|
||||
close: { [self] in await self.recordClose(connection: index) }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Manual injection (server side of the wire)
|
||||
|
||||
/// Yield one server JSON text frame into the latest connection
|
||||
/// (or queue it for the next connect — see type doc).
|
||||
public func emit(frame: String) {
|
||||
deliver(.frame(frame))
|
||||
}
|
||||
|
||||
/// Terminate the latest connection's stream with `error`
|
||||
/// (transport failure — the "stream throw" half of the contract).
|
||||
public func emitError(_ error: any Error) {
|
||||
deliver(.failure(error))
|
||||
}
|
||||
|
||||
/// Finish the latest connection's stream cleanly
|
||||
/// (server close — the "stream finish" half of the contract).
|
||||
public func finishFrames() {
|
||||
deliver(.finish)
|
||||
}
|
||||
|
||||
// MARK: - Recorded traffic
|
||||
|
||||
/// All frames sent by the client, flattened in connection order.
|
||||
public var sentFrames: [String] { connections.flatMap(\.sentFrames) }
|
||||
|
||||
/// Frames sent by the client, grouped per successful connection
|
||||
/// (reconnect tests assert the re-attach frame landed on connection 1).
|
||||
public var sentFramesByConnection: [[String]] { connections.map(\.sentFrames) }
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private func deliver(_ event: ServerEvent) {
|
||||
guard let index = liveConnectionIndex() else {
|
||||
pendingEvents.append(event)
|
||||
return
|
||||
}
|
||||
apply(event, to: index)
|
||||
}
|
||||
|
||||
private func liveConnectionIndex() -> Int? {
|
||||
guard let last = connections.indices.last, !connections[last].isTerminated else {
|
||||
return nil
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
private func apply(_ event: ServerEvent, to index: Int) {
|
||||
switch event {
|
||||
case .frame(let frame):
|
||||
connections[index].continuation.yield(frame)
|
||||
case .failure(let error):
|
||||
connections[index].isTerminated = true
|
||||
connections[index].continuation.finish(throwing: error)
|
||||
case .finish:
|
||||
connections[index].isTerminated = true
|
||||
connections[index].continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
private func flushPendingEvents(into index: Int) {
|
||||
let events = pendingEvents
|
||||
pendingEvents = []
|
||||
for event in events {
|
||||
apply(event, to: index)
|
||||
}
|
||||
}
|
||||
|
||||
private func recordSend(_ frame: String, connection index: Int) throws {
|
||||
guard !connections[index].isTerminated else {
|
||||
throw FakeTransportError.sendAfterClose
|
||||
}
|
||||
connections[index].sentFrames.append(frame)
|
||||
}
|
||||
|
||||
private func recordClose(connection index: Int) {
|
||||
closeCallCount += 1
|
||||
guard !connections[index].isTerminated else { return }
|
||||
connections[index].isTerminated = true
|
||||
connections[index].continuation.finish()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user