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:
Yaojia Wang
2026-07-04 21:19:30 +02:00
parent 9b41ffa574
commit cbaa08daba
46 changed files with 3203 additions and 3 deletions

View File

@@ -0,0 +1,21 @@
// swift-tools-version: 6.0
// T-iOS-4: test doubles leaned on by every W1-W3 task. Depends ONLY on
// WireProtocol (the frozen contract) never on SessionCore/HostRegistry/
// APIClient, so it compiles in W0 and leaf packages stay decoupled (plan §6).
import PackageDescription
let package = Package(
name: "TestSupport",
platforms: [.iOS(.v17), .macOS(.v14)],
products: [
.library(name: "TestSupport", targets: ["TestSupport"]),
],
dependencies: [
.package(path: "../WireProtocol"),
],
targets: [
.target(name: "TestSupport", dependencies: ["WireProtocol"]),
.testTarget(name: "TestSupportTests", dependencies: ["TestSupport"]),
],
swiftLanguageModes: [.v6]
)

View 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) }
}
}

View File

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

View 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 attachreplay) 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()
}
}

View File

@@ -0,0 +1,32 @@
import Testing
@testable import TestSupport
@Suite("FakeClock")
struct FakeClockTests {
private static let sleepDuration: Duration = .seconds(25)
private static let partialAdvance: Duration = .seconds(10)
private static let remainingAdvance: Duration = .seconds(15)
@Test("sleeper wakes only after the clock is manually advanced past its deadline")
func sleeperWakesOnlyAfterAdvancePastDeadline() async throws {
// Arrange
let clock = FakeClock()
let deadline = clock.now.advanced(by: Self.sleepDuration)
let sleeper = Task {
try await clock.sleep(until: deadline, tolerance: nil)
}
await clock.waitForSleepers(count: 1)
// Act: advance short of the deadline the sleeper must stay asleep.
clock.advance(by: Self.partialAdvance)
#expect(clock.pendingSleeperCount == 1)
// Act: advance up to the deadline the sleeper must wake.
clock.advance(by: Self.remainingAdvance)
try await sleeper.value
// Assert: zero real-time waiting, purely manual time.
#expect(clock.pendingSleeperCount == 0)
#expect(clock.now == FakeClock.Instant(offset: Self.sleepDuration))
}
}

View File

@@ -0,0 +1,42 @@
import Foundation
import Testing
@testable import TestSupport
@Suite("FakeHTTPTransport")
struct FakeHTTPTransportTests {
private static let originValue = "http://192.168.1.5:3000"
@Test("replays queued responses per URL/method, records requests with headers, and fails loudly when unqueued")
func replaysQueuedResponsesAndRecordsRequests() async throws {
// Arrange
let transport = FakeHTTPTransport()
let listURL = try #require(URL(string: "http://192.168.1.5:3000/live-sessions"))
let unqueuedURL = try #require(URL(string: "http://192.168.1.5:3000/other"))
let body = Data("[]".utf8)
await transport.queueSuccess(url: listURL, body: body)
var request = URLRequest(url: listURL)
request.httpMethod = "GET"
request.setValue(Self.originValue, forHTTPHeaderField: "Origin")
// Act
let (data, response) = try await transport.send(request)
// Assert: the queued response came back for that URL/method.
#expect(data == body)
#expect(response.statusCode == FakeHTTPTransport.defaultOKStatus)
#expect(response.url == listURL)
// Assert: the request was recorded verbatim, headers included
// (this is what lets APIClient tests assert Origin-iff-G, plan §3.4).
let recorded = await transport.recordedRequests
#expect(recorded.count == 1)
#expect(recorded.first?.url == listURL)
#expect(recorded.first?.value(forHTTPHeaderField: "Origin") == Self.originValue)
// Assert: an unqueued route throws an explicit, identifying error.
await #expect(throws: FakeHTTPTransportError.noQueuedResponse(method: "GET", url: unqueuedURL)) {
_ = try await transport.send(URLRequest(url: unqueuedURL))
}
}
}

View File

@@ -0,0 +1,41 @@
import Foundation
import Testing
import WireProtocol
@testable import TestSupport
@Suite("FakeTransport")
struct FakeTransportTests {
@Test("scripts connect failures, delivers injected frames, records sends and closes")
func scriptsFailuresDeliversFramesAndRecordsTraffic() async throws {
// Arrange
let transport = FakeTransport()
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
let attachFrame = #"{"type":"attach","sessionId":null}"#
// Act + Assert: a scripted failure makes the next connect throw it.
await transport.scriptConnectFailure()
await #expect(throws: FakeTransportError.scriptedConnectFailure) {
_ = try await transport.connect(to: endpoint)
}
// Act: connect for real, send one frame, inject one frame, close cleanly.
let connection = try await transport.connect(to: endpoint)
try await connection.send(attachFrame)
await transport.emit(frame: "server-frame-1")
await transport.finishFrames()
var received: [String] = []
for try await frame in connection.frames {
received.append(frame)
}
await connection.close()
// Assert: injected frames arrived in order and ended with a clean finish.
#expect(received == ["server-frame-1"])
// Assert: the double recorded everything the client did.
#expect(await transport.sentFrames == [attachFrame])
#expect(await transport.closeCallCount == 1)
#expect(await transport.connectAttempts == [endpoint, endpoint])
}
}