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,24 @@
// swift-tools-version: 6.0
// T-iOS-1 scaffold shell. APIClient / Endpoints / Models / PairingProbe /
// PairingError land in T-iOS-8 (P1 increments via T-iOS-38, single owner).
// Dependency direction is strictly downward: APIClient WireProtocol only.
import PackageDescription
let package = Package(
name: "APIClient",
platforms: [.iOS(.v17), .macOS(.v14)],
products: [
.library(name: "APIClient", targets: ["APIClient"]),
],
dependencies: [
.package(path: "../WireProtocol"),
],
targets: [
.target(
name: "APIClient",
dependencies: [.product(name: "WireProtocol", package: "WireProtocol")]
),
.testTarget(name: "APIClientTests", dependencies: ["APIClient"]),
],
swiftLanguageModes: [.v6]
)

View File

@@ -0,0 +1,12 @@
import WireProtocol
/// T-iOS-1 scaffold placeholder.
///
/// Real modules land in T-iOS-8: APIClient, Endpoints, Models, PairingProbe,
/// PairingError (Origin iff-G stamping is a frozen security semantic, §3.4/§5.1).
public enum APIClientPackage {
/// Package identity marker consumed by the scaffold smoke test.
public static let packageName = "APIClient"
/// Proves the single allowed dependency edge (APIClient WireProtocol) compiles.
public static let contractPackageName = WireProtocolPackage.packageName
}

View File

@@ -0,0 +1,13 @@
import Testing
import APIClient
@Test("脚手架:APIClient 包可编译且依赖边 APIClient→WireProtocol 成立")
func apiClientPackageIsImportableAndDependsOnWireProtocol() {
// Arrange / Act
let name = APIClientPackage.packageName
let contract = APIClientPackage.contractPackageName
// Assert
#expect(name == "APIClient")
#expect(contract == "WireProtocol")
}

View File

@@ -0,0 +1,24 @@
// swift-tools-version: 6.0
// T-iOS-1 scaffold shell. Host / HostStore / KeychainHostStore / SecItemShim /
// InMemoryHostStore / LastSessionStore land in T-iOS-7.
// Dependency direction is strictly downward: HostRegistry WireProtocol only.
import PackageDescription
let package = Package(
name: "HostRegistry",
platforms: [.iOS(.v17), .macOS(.v14)],
products: [
.library(name: "HostRegistry", targets: ["HostRegistry"]),
],
dependencies: [
.package(path: "../WireProtocol"),
],
targets: [
.target(
name: "HostRegistry",
dependencies: [.product(name: "WireProtocol", package: "WireProtocol")]
),
.testTarget(name: "HostRegistryTests", dependencies: ["HostRegistry"]),
],
swiftLanguageModes: [.v6]
)

View File

@@ -0,0 +1,12 @@
import WireProtocol
/// T-iOS-1 scaffold placeholder.
///
/// Real modules land in T-iOS-7: Host, HostStore, KeychainHostStore (via
/// SecItemShim seam), InMemoryHostStore, LastSessionStore.
public enum HostRegistryPackage {
/// Package identity marker consumed by the scaffold smoke test.
public static let packageName = "HostRegistry"
/// Proves the single allowed dependency edge (HostRegistry WireProtocol) compiles.
public static let contractPackageName = WireProtocolPackage.packageName
}

View File

@@ -0,0 +1,13 @@
import Testing
import HostRegistry
@Test("脚手架:HostRegistry 包可编译且依赖边 HostRegistry→WireProtocol 成立")
func hostRegistryPackageIsImportableAndDependsOnWireProtocol() {
// Arrange / Act
let name = HostRegistryPackage.packageName
let contract = HostRegistryPackage.contractPackageName
// Assert
#expect(name == "HostRegistry")
#expect(contract == "WireProtocol")
}

View File

@@ -0,0 +1,24 @@
// swift-tools-version: 6.0
// T-iOS-1 scaffold shell. SessionEngine / ReconnectMachine / PingScheduler /
// GateState / AwayDigest / URLSessionTermTransport land in W1W2 tasks.
// Dependency direction is strictly downward: SessionCore WireProtocol only.
import PackageDescription
let package = Package(
name: "SessionCore",
platforms: [.iOS(.v17), .macOS(.v14)],
products: [
.library(name: "SessionCore", targets: ["SessionCore"]),
],
dependencies: [
.package(path: "../WireProtocol"),
],
targets: [
.target(
name: "SessionCore",
dependencies: [.product(name: "WireProtocol", package: "WireProtocol")]
),
.testTarget(name: "SessionCoreTests", dependencies: ["SessionCore"]),
],
swiftLanguageModes: [.v6]
)

View File

@@ -0,0 +1,13 @@
import WireProtocol
/// T-iOS-1 scaffold placeholder.
///
/// Real modules land in their owning tasks: ReconnectMachine + PingScheduler
/// (T-iOS-5), GateState + AwayDigest (T-iOS-6), URLSessionTermTransport
/// (T-iOS-9), SessionEngine + SessionEvent (T-iOS-10), KeyByteMap (T-iOS-11).
public enum SessionCorePackage {
/// Package identity marker consumed by the scaffold smoke test.
public static let packageName = "SessionCore"
/// Proves the single allowed dependency edge (SessionCore WireProtocol) compiles.
public static let contractPackageName = WireProtocolPackage.packageName
}

View File

@@ -0,0 +1,13 @@
import Testing
import SessionCore
@Test("脚手架:SessionCore 包可编译且依赖边 SessionCore→WireProtocol 成立")
func sessionCorePackageIsImportableAndDependsOnWireProtocol() {
// Arrange / Act
let name = SessionCorePackage.packageName
let contract = SessionCorePackage.contractPackageName
// Assert
#expect(name == "SessionCore")
#expect(contract == "WireProtocol")
}

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

View File

@@ -0,0 +1,17 @@
// swift-tools-version: 6.0
// T-iOS-1 scaffold shell. The frozen wire contract (types + Tunables + shared
// I/O boundary types) lands in T-iOS-3, which owns ios/Packages/WireProtocol/**.
import PackageDescription
let package = Package(
name: "WireProtocol",
platforms: [.iOS(.v17), .macOS(.v14)],
products: [
.library(name: "WireProtocol", targets: ["WireProtocol"]),
],
targets: [
.target(name: "WireProtocol"),
.testTarget(name: "WireProtocolTests", dependencies: ["WireProtocol"]),
],
swiftLanguageModes: [.v6]
)

View File

@@ -0,0 +1,41 @@
import Foundation
/// Client server WS frames (frozen contract, plan §3.1; mirrors
/// `src/types.ts:87-92` `ClientMessage`). Immutable value type.
///
/// Server-side validation the caller must respect (the server SILENTLY DISCARDS
/// invalid frames, `src/protocol.ts:45-86` no error reply comes back):
/// - `attach` must be the FIRST frame on a connection (src/server.ts:707-711).
/// - `resize` cols/rows must be integers in `WireConstants.resizeRange` (1...1000).
/// - `attach.cwd`, when present, must be an absolute path (`Validation.isAbsoluteCwd`).
/// - `input.data` is raw keyboard bytes, passed through verbatim never filtered.
public enum ClientMessage: Sendable, Equatable {
/// First frame. `sessionId == nil` spawns a new session (encoded as an
/// explicit JSON `"sessionId":null` the key is REQUIRED by the server,
/// src/protocol.ts:132-134). `cwd` = "new tab here" spawn directory (M6).
case attach(sessionId: UUID?, cwd: String?)
/// Raw keyboard bytes, verbatim (invariant #9 no content filtering).
case input(data: String)
/// Own message type so the server can `ioctl(TIOCSWINSZ)` SIGWINCH.
case resize(cols: Int, rows: Int)
/// Resolve a held permission gate with allow. `mode` is only meaningful for
/// a `plan` gate and is encoded as a TOP-LEVEL `mode` key the server's WS
/// wiring re-parses the raw frame for it (src/server.ts:91-102).
case approve(mode: ApproveMode?)
/// Resolve a held permission gate with deny.
case reject
}
/// Permission mode written back when resolving a `plan` gate. Raw values mirror
/// the server whitelist `PERMISSION_MODES` (src/server.ts:76 / src/types.ts:365).
///
/// Note: the plan-gate three-way UI only ever sends `.acceptEdits` / `.default`
/// (mirroring public/tabs.ts:345-347). Raw `.auto` is gated by ALLOW_AUTO_MODE
/// (default false) and is server-downgraded to `default` otherwise
/// (src/server.ts:765-766); it is reserved for a future permission-mode switcher.
public enum ApproveMode: String, Sendable, CaseIterable {
case `default`
case acceptEdits
case plan
case auto
}

View File

@@ -0,0 +1,13 @@
import Foundation
/// Thin HTTP I/O boundary (frozen contract, plan §3.1). `APIClient` builds
/// `URLRequest`s (Origin stamped iff the endpoint is a mutating `G` route,
/// plan §3.4 ) and sends them through this seam; the production
/// implementation wraps `URLSession`, `FakeHTTPTransport` (TestSupport)
/// queues canned responses.
public protocol HTTPTransport: Sendable {
/// Perform one HTTP exchange. Implementations throw on transport-level
/// failure; non-2xx statuses are returned, not thrown classification
/// (e.g. `PairingError`) is the caller's job.
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)
}

View File

@@ -0,0 +1,89 @@
import Foundation
/// A paired web-terminal host (frozen contract, plan §3.1). The SINGLE point of
/// derivation for the `Origin` header and the WS URL hand-assembling either
/// anywhere else is a review CRITICAL (plan §5.1 / T-iOS-9 ).
///
/// Derivations are computed once at init and stored immutably:
/// - `originHeader` = `<scheme>://<host>[:<port>]`, omitting the scheme's
/// default port (http/80, https/443), scheme+host lowercased identical to
/// browser Origin serialization. The server normalises BOTH sides via
/// `new URL()` before comparing protocol/hostname/port (src/http/origin.ts:31-51).
/// - `wsURL` = same host+port, scheme httpws / httpswss, path replaced by
/// `WireConstants.wsPath`; query/fragment/credentials dropped.
public struct HostEndpoint: Sendable, Equatable, Codable {
/// The URL the user dialed: `http(s)://<host>[:<port>]`. Any path, query,
/// fragment or credentials it carries are ignored by the derivations.
public let baseURL: URL
/// Derived WS endpoint (`ws(s):///term`). Stored at init; contract-wise a
/// read-only property, per plan §3.1.
public let wsURL: URL
/// Derived `Origin` header value see type doc. Never hand-assemble.
public let originHeader: String
private static let wsSchemeByHTTPScheme = ["http": "ws", "https": "wss"]
private static let defaultPortByScheme = ["http": 80, "https": 443]
/// Validating init: the URL must be http(s) with a non-empty host, else nil
/// (QR-scan payloads are untrusted external input reject early, plan §5).
public init?(baseURL: URL) {
guard let components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true),
let scheme = components.scheme?.lowercased(),
let wsScheme = Self.wsSchemeByHTTPScheme[scheme],
let rawHost = components.host, !rawHost.isEmpty,
let wsURL = Self.deriveWSURL(from: components, wsScheme: wsScheme)
else { return nil }
self.baseURL = baseURL
self.wsURL = wsURL
self.originHeader = Self.deriveOrigin(
scheme: scheme, host: rawHost.lowercased(), port: components.port
)
}
private static func deriveOrigin(scheme: String, host: String, port: Int?) -> String {
// IPv6 literals need brackets in an Origin. URLComponents.host has
// returned them both bare and pre-bracketed across Foundation versions
// wrap idempotently.
let needsBrackets = host.contains(":") && !host.hasPrefix("[")
let serializedHost = needsBrackets ? "[\(host)]" : host
guard let port, port != defaultPortByScheme[scheme] else {
return "\(scheme)://\(serializedHost)"
}
return "\(scheme)://\(serializedHost):\(port)"
}
private static func deriveWSURL(from components: URLComponents, wsScheme: String) -> URL? {
var wsComponents = components
wsComponents.scheme = wsScheme
wsComponents.path = WireConstants.wsPath
wsComponents.query = nil
wsComponents.fragment = nil
wsComponents.user = nil
wsComponents.password = nil
return wsComponents.url
}
// MARK: - Codable (persisted via HostRegistry/Keychain re-validate on decode)
private enum CodingKeys: String, CodingKey {
case baseURL
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let url = try container.decode(URL.self, forKey: .baseURL)
guard let endpoint = HostEndpoint(baseURL: url) else {
throw DecodingError.dataCorruptedError(
forKey: .baseURL, in: container,
debugDescription: "baseURL is not an http(s) URL with a host"
)
}
self = endpoint
}
public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(baseURL, forKey: .baseURL)
}
}

View File

@@ -0,0 +1,153 @@
import Foundation
/// Pure static codec for the WS text-frame protocol (frozen contract, plan §3.1).
/// NEVER throws in either direction:
/// - `encode` is total every `ClientMessage` has a JSON representation.
/// - `decodeServer` mirrors the server's "invalid frames are silently discarded"
/// resilience (src/protocol.ts:45-86, src/server.ts:698-701): malformed input `nil`.
public enum MessageCodec {
// MARK: - encode (client server)
/// Encode a `ClientMessage` as a JSON text frame accepted by the server's
/// `parseClientMessage` (src/protocol.ts). String escaping matches
/// `JSON.stringify` byte-for-byte (short escapes for \b \t \n \f \r, and
/// `\u00XX` lowercase-hex for other C0 control bytes) so a Swift client is
/// indistinguishable from the web client on the wire.
///
/// Key shapes (each pinned by CodecRoundtripTests):
/// - `attach` ALWAYS carries an explicit `sessionId` key JSON `null` when
/// nil (the server requires the key's presence, src/protocol.ts:132-134);
/// UUIDs are lowercased (crypto.randomUUID style; SESSION_ID_RE is /i).
/// - `approve` puts `mode` as a TOP-LEVEL key: the server's WS wiring
/// re-parses the RAW frame for `obj['mode']` (src/server.ts:91-102).
/// - `resize` emits bare integers; `input.data` is escaped verbatim.
public static func encode(_ message: ClientMessage) -> String {
switch message {
case let .attach(sessionId, cwd):
return encodeAttach(sessionId: sessionId, cwd: cwd)
case let .input(data):
return "{\"type\":\"input\",\"data\":\(jsonStringLiteral(data))}"
case let .resize(cols, rows):
return "{\"type\":\"resize\",\"cols\":\(cols),\"rows\":\(rows)}"
case let .approve(mode):
guard let mode else { return "{\"type\":\"approve\"}" }
return "{\"type\":\"approve\",\"mode\":\"\(mode.rawValue)\"}"
case .reject:
return "{\"type\":\"reject\"}"
}
}
private static func encodeAttach(sessionId: UUID?, cwd: String?) -> String {
let sessionIdJSON = sessionId.map { "\"\($0.uuidString.lowercased())\"" } ?? "null"
let cwdPart = cwd.map { ",\"cwd\":\(jsonStringLiteral($0))" } ?? ""
return "{\"type\":\"attach\",\"sessionId\":\(sessionIdJSON)\(cwdPart)}"
}
/// JSON string literal with `JSON.stringify`-identical escaping.
private static func jsonStringLiteral(_ value: String) -> String {
var out = "\""
for scalar in value.unicodeScalars {
switch scalar {
case "\"": out += "\\\""
case "\\": out += "\\\\"
case "\u{08}": out += "\\b"
case "\t": out += "\\t"
case "\n": out += "\\n"
case "\u{0C}": out += "\\f"
case "\r": out += "\\r"
default:
if scalar.value < 0x20 {
out += String(format: "\\u%04x", Int(scalar.value))
} else {
out.unicodeScalars.append(scalar)
}
}
}
return out + "\""
}
// MARK: - decodeServer (server client, untrusted)
/// Decode a server JSON text frame. Whitelist semantics, never throws:
/// bad JSON, non-object frames, unknown `type`, or missing/wrong-typed
/// REQUIRED fields `nil` (drop the frame). Wrong-typed OPTIONAL fields
/// are tolerated as absent (see per-case doc on `ServerMessage`).
public static func decodeServer(_ text: String) -> ServerMessage? {
guard let data = text.data(using: .utf8),
let frame = try? JSONDecoder().decode(ServerFrame.self, from: data)
else { return nil }
return frame.message
}
}
// MARK: - Internal decoding scaffolding
/// Decodable shim so one JSONDecoder pass yields `ServerMessage?` without ever
/// surfacing a throw for merely-malformed content (only structurally non-JSON
/// input makes JSONDecoder itself throw, which `decodeServer` catches).
private struct ServerFrame: Decodable {
let message: ServerMessage?
private enum Keys: String, CodingKey {
case type, sessionId, data, code, reason, status, detail, pending, gate, telemetry
}
init(from decoder: any Decoder) throws {
guard let container = try? decoder.container(keyedBy: Keys.self),
let type = try? container.decode(String.self, forKey: .type)
else {
message = nil
return
}
message = Self.decodeBody(type: type, from: container)
}
private static func decodeBody(
type: String, from container: KeyedDecodingContainer<Keys>
) -> ServerMessage? {
switch type {
case "attached":
return decodeAttached(container)
case "output":
guard let data = try? container.decode(String.self, forKey: .data) else { return nil }
return .output(data: data)
case "exit":
return decodeExit(container)
case "status":
return decodeStatus(container)
case "telemetry":
guard let telemetry = try? container.decode(StatusTelemetry.self, forKey: .telemetry)
else { return nil }
return .telemetry(telemetry)
default:
return nil
}
}
/// `attached.sessionId` must pass the server's own SESSION_ID_RE (M7)
/// `UUID(uuidString:)` alone would accept non-v4 UUIDs.
private static func decodeAttached(_ container: KeyedDecodingContainer<Keys>) -> ServerMessage? {
guard let raw = try? container.decode(String.self, forKey: .sessionId),
Validation.isValidSessionId(raw),
let sessionId = UUID(uuidString: raw)
else { return nil }
return .attached(sessionId: sessionId)
}
private static func decodeExit(_ container: KeyedDecodingContainer<Keys>) -> ServerMessage? {
guard let code = try? container.decode(Int.self, forKey: .code) else { return nil }
let reason = try? container.decode(String.self, forKey: .reason)
return .exit(code: code, reason: reason)
}
private static func decodeStatus(_ container: KeyedDecodingContainer<Keys>) -> ServerMessage? {
guard let rawStatus = try? container.decode(String.self, forKey: .status),
let status = ClaudeStatus(rawValue: rawStatus)
else { return nil }
let detail = try? container.decode(String.self, forKey: .detail)
let pending = (try? container.decode(Bool.self, forKey: .pending)) ?? false
let gate = (try? container.decode(String.self, forKey: .gate))
.flatMap(GateKind.init(rawValue:))
return .status(status, detail: detail, pending: pending, gate: gate)
}
}

View File

@@ -0,0 +1,118 @@
import Foundation
/// Server client WS frames (frozen contract, plan §3.1; mirrors
/// `src/types.ts:109-120` `ServerMessage`). Decoded ONLY via
/// `MessageCodec.decodeServer` the server is an untrusted input source and
/// malformed frames become `nil`, never a crash.
public enum ServerMessage: Sendable, Equatable {
/// Attach confirmation. ALWAYS adopt the server-issued id attaching with
/// an unknown UUID yields a fresh session id (src/session/manager.ts:108-174).
case attached(sessionId: UUID)
/// Opaque ANSI/UTF-8 bytes; ring-buffer replay and live stream share this shape.
case output(data: String)
/// Shell exit. `code == WireConstants.spawnFailedExitCode` (-1) means the
/// spawn never succeeded (M4) and `reason` is required server-side.
case exit(code: Int, reason: String?)
/// Claude Code activity derived from hooks (H2/H3/B4). `pending` = a tool
/// approval is held server-side; `gate` says which kind. Absent `pending`
/// decodes as `false`; an unrecognized `gate` value decodes as `nil`
/// (tolerated as absent so the pending signal is never lost).
case status(ClaudeStatus, detail: String?, pending: Bool, gate: GateKind?)
/// Latest statusLine telemetry broadcast (B2).
case telemetry(StatusTelemetry)
}
/// Mirrors `src/types.ts:97` `ClaudeStatus`. `unknown` = no hook signal yet;
/// `stuck` (A5) = output silent past STUCK_TTL while not idle/exited.
public enum ClaudeStatus: String, Sendable {
case working, waiting, idle, unknown, stuck
}
/// Mirrors `src/types.ts:101` `PermissionGate`. `plan` = ExitPlanMode three-way
/// gate; `tool` = ordinary tool gate.
public enum GateKind: String, Sendable {
case tool, plan
}
/// Mirrors `src/types.ts:406-416` `StatusTelemetry`: every metric optional,
/// `at` (server receive time, ms since epoch) required. Decoding is tolerant
/// a wrong-typed OPTIONAL field is treated as absent (mirrors the server's own
/// tolerant statusLine parsing); a missing/wrong-typed `at` fails the decode.
public struct StatusTelemetry: Sendable, Equatable, Decodable {
public let contextUsedPct: Double?
public let costUsd: Double?
public let linesAdded: Int?
public let linesRemoved: Int?
public let model: String?
public let effort: String?
public let pr: PrInfo?
public let rate: RateInfo?
/// Server receive timestamp (ms). REQUIRED frames without it are dropped.
public let at: Int
public init(
contextUsedPct: Double? = nil,
costUsd: Double? = nil,
linesAdded: Int? = nil,
linesRemoved: Int? = nil,
model: String? = nil,
effort: String? = nil,
pr: PrInfo? = nil,
rate: RateInfo? = nil,
at: Int
) {
self.contextUsedPct = contextUsedPct
self.costUsd = costUsd
self.linesAdded = linesAdded
self.linesRemoved = linesRemoved
self.model = model
self.effort = effort
self.pr = pr
self.rate = rate
self.at = at
}
private enum CodingKeys: String, CodingKey {
case contextUsedPct, costUsd, linesAdded, linesRemoved
case model, effort, pr, rate, at
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// `at` is the only required field; its absence invalidates the frame.
at = try container.decode(Int.self, forKey: .at)
// Optional fields: `try?` treats wrong-typed values as absent (tolerant).
contextUsedPct = try? container.decode(Double.self, forKey: .contextUsedPct)
costUsd = try? container.decode(Double.self, forKey: .costUsd)
linesAdded = try? container.decode(Int.self, forKey: .linesAdded)
linesRemoved = try? container.decode(Int.self, forKey: .linesRemoved)
model = try? container.decode(String.self, forKey: .model)
effort = try? container.decode(String.self, forKey: .effort)
pr = try? container.decode(PrInfo.self, forKey: .pr)
rate = try? container.decode(RateInfo.self, forKey: .rate)
}
}
/// Mirrors `src/types.ts:413` `StatusTelemetry.pr`.
public struct PrInfo: Sendable, Equatable, Decodable {
public let number: Int
public let url: String
public let reviewState: String?
public init(number: Int, url: String, reviewState: String? = nil) {
self.number = number
self.url = url
self.reviewState = reviewState
}
}
/// Mirrors `src/types.ts:414` `StatusTelemetry.rate`.
public struct RateInfo: Sendable, Equatable, Decodable {
public let fiveHourPct: Double?
public let sevenDayPct: Double?
public init(fiveHourPct: Double? = nil, sevenDayPct: Double? = nil) {
self.fiveHourPct = fiveHourPct
self.sevenDayPct = sevenDayPct
}
}

View File

@@ -0,0 +1,30 @@
/// The ONLY WS I/O boundary (frozen contract, plan §3.1/§3.2).
/// `URLSessionTermTransport` (SessionCore, T-iOS-9) and `FakeTransport`
/// (TestSupport, T-iOS-4) both implement this; `SessionEngine` cannot tell
/// them apart.
public protocol TermTransport: Sendable {
/// Open a WS connection to `endpoint.wsURL`, stamping
/// `Origin: endpoint.originHeader` on the upgrade (plan §5.1).
func connect(to endpoint: HostEndpoint) async throws -> TransportConnection
}
/// One live WS connection, as immutable capability handles (frozen contract).
public struct TransportConnection: Sendable {
/// Server JSON text frames, in arrival order. Stream finish = clean close;
/// stream throw = transport error (the two are distinguishable, T-iOS-9).
public let frames: AsyncThrowingStream<String, any Error>
/// Send one client JSON text frame (produced by `MessageCodec.encode`).
public let send: @Sendable (String) async throws -> Void
/// Close the connection (client detach the server-side PTY keeps running).
public let close: @Sendable () async -> Void
public init(
frames: AsyncThrowingStream<String, any Error>,
send: @escaping @Sendable (String) async throws -> Void,
close: @escaping @Sendable () async -> Void
) {
self.frames = frames
self.send = send
self.close = close
}
}

View File

@@ -0,0 +1,53 @@
import Foundation
/// One activity-timeline entry from `GET /live-sessions/:id/events` (frozen
/// contract, plan §3.1; mirrors src/types.ts:428-433). The server derives
/// `class` semantically (src/types.ts:423: tool/waiting/done/stuck/user) the
/// client never re-derives it from hook names. `class` stays a raw `String` so
/// the SHAPE decodes even for future/unknown classes; consumers drop unknowns
/// (use `decodeList`, which does exactly that).
public struct TimelineEvent: Sendable, Equatable, Decodable {
/// Server ingest timestamp (ms since epoch).
public let at: Int
/// Server-derived semantic class; see `knownClasses`.
public let `class`: String
/// Sanitized tool name (server-side: 200 chars, control chars stripped).
public let toolName: String?
/// Server-derived human phrase ("ran Bash", "edited 3 files").
public let label: String
/// The server's `TimelineClass` union (src/types.ts:423).
public static let knownClasses: Set<String> = ["tool", "waiting", "done", "stuck", "user"]
public init(at: Int, class className: String, toolName: String?, label: String) {
self.at = at
self.`class` = className
self.toolName = toolName
self.label = label
}
/// True when `class` is one the server currently emits.
public var hasKnownClass: Bool {
Self.knownClasses.contains(`class`)
}
/// Decode a `/events` response body. NEVER throws: the server is an
/// untrusted input source malformed entries and unknown-class entries are
/// silently dropped; a non-array body yields `[]`.
public static func decodeList(from data: Data) -> [TimelineEvent] {
guard let entries = try? JSONDecoder().decode([LossyEntry].self, from: data) else {
return []
}
return entries.compactMap(\.value).filter(\.hasKnownClass)
}
}
/// Per-element tolerance shim: a malformed element becomes `nil` instead of
/// failing the whole array decode.
private struct LossyEntry: Decodable {
let value: TimelineEvent?
init(from decoder: any Decoder) {
value = try? TimelineEvent(from: decoder)
}
}

View File

@@ -0,0 +1,44 @@
/// Client-side tuning constants (frozen contract; SOLE value source is the
/// plan §3.2.1 table adding/changing a constant goes back through T-iOS-3).
/// All named no magic numbers anywhere downstream.
public enum Tunables {
/// WS keep-alive ping period. `URLSessionWebSocketTask` has NO automatic
/// ping (plan §1) `PingScheduler` drives an explicit `sendPing` at this
/// interval so the terminal is never "looks connected but dead".
public static let pingInterval: Duration = .seconds(25)
/// Consecutive missed pongs after which the connection is declared dead
/// (T-iOS-5): 1 missed pong is tolerated, the 2nd is a disconnect signal.
public static let pongMissLimit = 2
/// Foreground `/live-sessions` polling period. Mirrors the web launcher's
/// refresh cadence (public/launcher.ts:30 `REFRESH_MS = 5000`).
public static let listPollInterval: Duration = .seconds(5)
/// Telemetry chips grey out when `StatusTelemetry.at` is older than this.
/// Mirrors public/tabs.ts:45 `STATUSLINE_TTL_MS` (= server default,
/// src/config.ts:63). The server value is env-overridable at runtime while
/// iOS bakes the default accepted drift (plan §3.2.1).
public static let telemetryStaleTtlMs: Int = 30_000
/// Away-digest banner auto-fade delay (T-iOS-14).
public static let digestFadeDelay: Duration = .seconds(8)
/// Max session-title length after sanitisation (T-iOS-23; OSC titles are
/// host/attacker-controlled input).
public static let titleMaxLength = 256
/// `URLSessionWebSocketTask.maximumMessageSize`. The default (1 MiB) is too
/// small: ring-buffer replay arrives as ONE full-snapshot frame
/// (src/session/session.ts:165-171) and JSON escaping inflates control
/// bytes to `\uXXXX` by 1-6x (src/protocol.ts:186), so worst case is
/// 6 × SCROLLBACK_BYTES (default 2 MiB) plus frame envelope 16 MiB.
///
/// COUPLING WARNING: SCROLLBACK_BYTES is a server env knob the client
/// cannot discover at runtime (no config handshake). If the host raises it
/// past ~2.7 MB (or replay is extremely escape-dense), `receive()` fails
/// with NSPOSIXErrorDomain code 40 (ENOBUFS "Message too long") that MUST
/// be classified as the non-retryable `.replayTooLarge` failure and never
/// fed into the backoff reconnect loop (plan §3.2 / T-iOS-9/10).
public static let maxWSMessageBytes = 16 * 1024 * 1024
}

View File

@@ -0,0 +1,31 @@
import Foundation
/// Boundary validation, same rules as the server (frozen contract, plan §3.1).
/// Sources: src/protocol.ts:22-23 (SESSION_ID_RE), :113-115 (dimensions),
/// :142-149 (cwd). Pure functions, never throw.
public enum Validation {
/// Byte-identical port of the server's SESSION_ID_RE
/// (`/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i`):
/// UUID v4 8-4-4-4-12 hex groups, version nibble '4', variant nibble
/// 8/9/a/b, case-insensitive (M7). Vectors in ServerVectorTests pin the
/// equivalence against a test-side mirror of the server regex.
public static func isValidSessionId(_ candidate: String) -> Bool {
let sessionIdRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
.ignoresCase()
return candidate.wholeMatch(of: sessionIdRegex) != nil
}
/// Mirrors the server's `isValidDimension` range check for `resize`
/// (integers in `WireConstants.resizeRange`). Frames outside this range
/// are silently discarded by the server validate before sending.
public static func isValidResize(cols: Int, rows: Int) -> Bool {
WireConstants.resizeRange.contains(cols) && WireConstants.resizeRange.contains(rows)
}
/// Mirrors the server's `attach.cwd` check: must start with "/"
/// (src/protocol.ts:142-149; deeper normalisation stays server-side).
public static func isAbsoluteCwd(_ candidate: String) -> Bool {
candidate.hasPrefix("/")
}
}

View File

@@ -0,0 +1,17 @@
/// Wire-level constants shared with the server (frozen contract, plan §3.1).
public enum WireConstants {
/// WS endpoint path (src/config.ts:41 `DEFAULT_WS_PATH`).
public static let wsPath = "/term"
/// Soft-reset prefix the server prepends to ring-buffer replay as a safety
/// net (src/types.ts:167-170 / M2). Clients may use it to recognise the
/// replay boundary; never strip it it is valid ANSI for the terminal.
public static let replaySoftResetPrefix = "\u{1B}[0m"
/// `exit.code` value meaning the PTY spawn never succeeded (M4);
/// `exit.reason` is required server-side in that case. Not a retryable state.
public static let spawnFailedExitCode = -1
/// Valid `resize` cols/rows range, inclusive (src/protocol.ts:113-115).
public static let resizeRange: ClosedRange<Int> = 1...1000
}

View File

@@ -0,0 +1,11 @@
/// T-iOS-1 scaffold placeholder.
///
/// The frozen wire contract (`ClientMessage` / `ServerMessage` / `MessageCodec` /
/// `Validation` / `WireConstants` / `Tunables` plus the shared I/O boundary types
/// `HostEndpoint` / `TermTransport` / `HTTPTransport` / `TimelineEvent`) lands in
/// T-iOS-3 and is owned exclusively by that task do not add contract types here.
public enum WireProtocolPackage {
/// Package identity marker consumed by the scaffold smoke tests (downstream
/// packages reference it to prove their dependency edge compiles).
public static let packageName = "WireProtocol"
}

View File

@@ -0,0 +1,215 @@
import Foundation
import Testing
import WireProtocol
// T-iOS-3 · MessageCodec.encode parseClientMessage + roundtrip
// property + fuzzsrc/protocol.tssrc/server.ts:91-102test/protocol.test.ts
// MARK: - encode
@Test("encode(attach) 无 sessionId 时也显式携带 sessionId:nullsrc/protocol.ts:132-134")
func encodeAttachCarriesExplicitNullSessionId() {
// Arrange / Act
let frame = MessageCodec.encode(.attach(sessionId: nil, cwd: nil))
// Assert +
#expect(frame == "{\"type\":\"attach\",\"sessionId\":null}")
#expect(ServerViewParser.parse(frame) == .attach(sessionId: nil, cwd: nil))
}
@Test("encode(attach) 带 UUID → 服务器收到小写 UUID 字符串")
func encodeAttachSerializesLowercasedUUID() {
// Arrange
let uuid = UUID(uuidString: "F47AC10B-58CC-4372-A567-0E02B2C3D479")!
// Act
let frame = MessageCodec.encode(.attach(sessionId: uuid, cwd: nil))
// Assert
#expect(frame == "{\"type\":\"attach\",\"sessionId\":\"f47ac10b-58cc-4372-a567-0e02b2c3d479\"}")
#expect(ServerViewParser.parse(frame)
== .attach(sessionId: "f47ac10b-58cc-4372-a567-0e02b2c3d479", cwd: nil))
}
@Test("encode(attach) 带 cwd → cwd 键出现且为绝对路径原文;无 cwd 时键不出现")
func encodeAttachCwdKeyAppearsOnlyWhenPresent() throws {
// Arrange / Act
let withCwd = MessageCodec.encode(.attach(sessionId: nil, cwd: "/Users/dev/proj"))
let withoutCwd = MessageCodec.encode(.attach(sessionId: nil, cwd: nil))
// Assert
#expect(ServerViewParser.parse(withCwd) == .attach(sessionId: nil, cwd: "/Users/dev/proj"))
let withoutObj = try #require(jsonObject(withoutCwd))
#expect(withoutObj.index(forKey: "cwd") == nil)
}
@Test("encode(input) 原始键盘字节逐字节透传Esc/^C/CR/Tab/Shift+Tab")
func encodeInputPassesRawKeyboardBytesVerbatim() {
// Arrange test/protocol.test.ts:79 verbatim
let raw = "\u{1B}[A\u{03}\r\t\u{1B}[Z"
// Act
let frame = MessageCodec.encode(.input(data: raw))
// Assert
#expect(ServerViewParser.parse(frame) == .input(data: raw))
}
@Test("encode(input) 控制字节按 JSON.stringify 规则转义(\\r 短转义、ESC → \\u001b")
func encodeInputEscapesControlBytesLikeJSONStringify() {
// Arrange / Act
let frame = MessageCodec.encode(.input(data: "hi\r\u{1B}[A\"\\"))
// Assert JS JSON.stringify
#expect(frame == "{\"type\":\"input\",\"data\":\"hi\\r\\u001b[A\\\"\\\\\"}")
}
@Test("encode(resize) cols/rows 为 JSON 整数,服务器按 [1,1000] 接受")
func encodeResizeEmitsIntegers() {
// Arrange / Act
let frame = MessageCodec.encode(.resize(cols: 120, rows: 40))
// Assert
#expect(frame == "{\"type\":\"resize\",\"cols\":120,\"rows\":40}")
#expect(ServerViewParser.parse(frame) == .resize(cols: 120, rows: 40))
#expect(ServerViewParser.parse(MessageCodec.encode(.resize(cols: 1, rows: 1)))
== .resize(cols: 1, rows: 1))
#expect(ServerViewParser.parse(MessageCodec.encode(.resize(cols: 1000, rows: 1000)))
== .resize(cols: 1000, rows: 1000))
}
@Test("encode(approve) 无 mode → 裸 {type:approve}encode(reject) → {type:reject}")
func encodeApproveRejectBareFrames() {
// Arrange / Act / Assert
#expect(MessageCodec.encode(.approve(mode: nil)) == "{\"type\":\"approve\"}")
#expect(MessageCodec.encode(.reject) == "{\"type\":\"reject\"}")
#expect(ServerViewParser.parse("{\"type\":\"approve\"}") == .approve)
#expect(ServerViewParser.parse("{\"type\":\"reject\"}") == .reject)
}
@Test("encode(approve.mode) 把 mode 放在顶层键src/server.ts:94-102 读原始帧 obj['mode']",
arguments: ApproveMode.allCases)
func encodeApproveModeIsTopLevelKey(mode: ApproveMode) throws {
// Arrange / Act
let frame = MessageCodec.encode(.approve(mode: mode))
// Assert parseClientMessage
#expect(ServerViewParser.parse(frame) == .approve)
// WS 线 parseApproveMode mode
#expect(ServerViewParser.topLevelMode(frame) == mode.rawValue)
let obj = try #require(jsonObject(frame))
#expect(obj["mode"] as? String == mode.rawValue)
}
@Test("ApproveMode rawValue 与服务器 PERMISSION_MODES 白名单一致src/server.ts:76")
func approveModeRawValuesMatchServerWhitelist() {
// Arrange / Act
let rawValues = Set(ApproveMode.allCases.map(\.rawValue))
// Assert
#expect(rawValues == ServerViewParser.permissionModes)
}
// MARK: - roundtrip propertyapprove.mode modesrc/protocol.ts:77-79
@Test("roundtrip property任意合法 ClientMessage encode→(服务器视角)decode 不变形")
func roundtripPropertyHoldsForRandomClientMessages() {
// Arrange
var rng = SplitMix64(seed: 0xC0FF_EE00_0003)
let iterations = 300
for _ in 0..<iterations {
let original = MessageGen.randomClientMessage(using: &rng)
// Act
let frame = MessageCodec.encode(original)
let parsed = ServerViewParser.parse(frame)
// Assert
assertServerViewMatches(original: original, frame: frame, parsed: parsed)
}
}
private func assertServerViewMatches(
original: ClientMessage, frame: String, parsed: ServerViewMessage?
) {
switch (original, parsed) {
case let (.attach(sessionId, cwd), .attach(parsedSessionId, parsedCwd)):
#expect(parsedSessionId == sessionId.map { $0.uuidString.lowercased() })
#expect(parsedCwd == cwd)
case let (.input(data), .input(parsedData)):
#expect(parsedData == data)
case let (.resize(cols, rows), .resize(parsedCols, parsedRows)):
#expect(parsedCols == cols)
#expect(parsedRows == rows)
case let (.approve(mode), .approve):
// approve + mode
#expect(ServerViewParser.topLevelMode(frame) == mode?.rawValue)
case (.reject, .reject):
break
default:
Issue.record("服务器视角解析结果与原消息 case 不符: \(original)\(String(describing: parsed)), frame=\(frame)")
}
}
// MARK: - fuzzdecodeServer crash
@Test("fuzz300 轮随机字节喂 decodeServer 不 crash非法 → nil")
func decodeServerSurvivesRandomByteFuzz() {
// Arrange
var rng = SplitMix64(seed: 0xDEAD_BEEF_0003)
for _ in 0..<300 {
let length = Int.random(in: 0...80, using: &rng)
let bytes = (0..<length).map { _ in UInt8.random(in: 0...255, using: &rng) }
let text = String(decoding: bytes, as: UTF8.self)
// Act throw / crash
_ = MessageCodec.decodeServer(text)
}
}
@Test("fuzz合法帧随机单字节突变喂 decodeServer 不 crash")
func decodeServerSurvivesMutatedValidFrames() {
// Arrange
var rng = SplitMix64(seed: 0xFEED_FACE_0003)
let seeds = [
"{\"type\":\"attached\",\"sessionId\":\"f47ac10b-58cc-4372-a567-0e02b2c3d479\"}",
"{\"type\":\"output\",\"data\":\"\\u001b[1;32mHello\\u001b[0m\"}",
"{\"type\":\"exit\",\"code\":-1,\"reason\":\"spawn failed\"}",
"{\"type\":\"status\",\"status\":\"waiting\",\"pending\":true,\"gate\":\"plan\"}",
"{\"type\":\"telemetry\",\"telemetry\":{\"at\":1751600000000,\"costUsd\":1.5}}",
]
for seed in seeds {
for _ in 0..<40 {
var bytes = Array(seed.utf8)
let index = Int.random(in: 0..<bytes.count, using: &rng)
bytes[index] = UInt8.random(in: 0...255, using: &rng)
// Act crash
_ = MessageCodec.decodeServer(String(decoding: bytes, as: UTF8.self))
}
}
}
@Test("客户端帧类型attach/input/resize/approve/reject不是服务器帧 → decodeServer nil")
func decodeServerRejectsClientFrameTypes() {
// Arrange
let clientFrames: [ClientMessage] = [
.attach(sessionId: nil, cwd: nil), .input(data: "x"),
.resize(cols: 80, rows: 24), .approve(mode: .plan), .reject,
]
for message in clientFrames {
// Act / Assert
#expect(MessageCodec.decodeServer(MessageCodec.encode(message)) == nil)
}
}
// MARK: - helpers
private func jsonObject(_ text: String) -> [String: Any]? {
guard let data = text.data(using: .utf8) else { return nil }
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
}

View File

@@ -0,0 +1,79 @@
import Foundation
import Testing
import WireProtocol
// T-iOS-3 · 线Tunables§3.2.1 WireConstants
// T-iOS-3
@Test("Tunables 取值与 §3.2.1 表逐项一致")
func tunablesMatchFrozenValueTable() {
#expect(Tunables.pingInterval == .seconds(25))
#expect(Tunables.pongMissLimit == 2)
#expect(Tunables.listPollInterval == .seconds(5)) // public/launcher.ts:30 REFRESH_MS
#expect(Tunables.telemetryStaleTtlMs == 30_000) // public/tabs.ts:45 STATUSLINE_TTL_MS
#expect(Tunables.digestFadeDelay == .seconds(8))
#expect(Tunables.titleMaxLength == 256)
#expect(Tunables.maxWSMessageBytes == 16 * 1024 * 1024)
}
@Test("maxWSMessageBytes ≥ 6 × 默认 SCROLLBACK_BYTES(2MiB)JSON 转义最坏膨胀系数)")
func maxWSMessageBytesCoversWorstCaseReplayExpansion() {
// Arrange src/config.ts:39 DEFAULT_SCROLLBACK_BYTES = 2MiB\uXXXX 6×
let defaultScrollbackBytes = 2 * 1024 * 1024
let worstCaseEscapeFactor = 6
// Assert
#expect(Tunables.maxWSMessageBytes >= worstCaseEscapeFactor * defaultScrollbackBytes)
}
@Test("WireConstantswsPath/soft-reset 前缀/spawn 失败码/resize 区间")
func wireConstantsMatchServer() {
#expect(WireConstants.wsPath == "/term") // src/config.ts:41 DEFAULT_WS_PATH
#expect(WireConstants.replaySoftResetPrefix == "\u{1B}[0m") // src/types.ts:167-170
#expect(WireConstants.spawnFailedExitCode == -1) // M4
#expect(WireConstants.resizeRange == 1...1000) // src/protocol.ts:113-115
}
@Test("TransportConnection能力句柄原样保存frames finish = 干净断线")
func transportConnectionHoldsCapabilityHandles() async throws {
// Arrange
actor Recorder {
var sentFrames: [String] = []
var isClosed = false
func recordSend(_ frame: String) { sentFrames.append(frame) }
func recordClose() { isClosed = true }
}
let recorder = Recorder()
let connection = TransportConnection(
frames: AsyncThrowingStream { continuation in
continuation.yield("{\"type\":\"output\",\"data\":\"x\"}")
continuation.finish()
},
send: { await recorder.recordSend($0) },
close: { await recorder.recordClose() }
)
// Act
var received: [String] = []
for try await frame in connection.frames {
received.append(frame)
}
try await connection.send("{\"type\":\"reject\"}")
await connection.close()
// Assert
#expect(received == ["{\"type\":\"output\",\"data\":\"x\"}"])
#expect(await recorder.sentFrames == ["{\"type\":\"reject\"}"])
#expect(await recorder.isClosed)
}
@Test("ClaudeStatus/GateKind rawValue 与服务器字面量一致")
func statusAndGateRawValuesMatchServer() {
#expect(ClaudeStatus.working.rawValue == "working")
#expect(ClaudeStatus.waiting.rawValue == "waiting")
#expect(ClaudeStatus.idle.rawValue == "idle")
#expect(ClaudeStatus.unknown.rawValue == "unknown")
#expect(ClaudeStatus.stuck.rawValue == "stuck")
#expect(GateKind.tool.rawValue == "tool")
#expect(GateKind.plan.rawValue == "plan")
}

View File

@@ -0,0 +1,126 @@
import Foundation
import Testing
import WireProtocol
// T-iOS-3 · HostEndpoint originHeader/wsURL T-iOS-7 plan §7
// Origin plan §5.1 Origin
private func endpoint(_ urlString: String) -> HostEndpoint? {
guard let url = URL(string: urlString) else { return nil }
return HostEndpoint(baseURL: url)
}
// MARK: - originHeader
@Test("originHeaderhttp + IPv4 + 非默认端口 → 与 baseURL 同串")
func originHeaderKeepsNonDefaultPortIPv4() throws {
let sut = try #require(endpoint("http://192.168.1.5:3000"))
#expect(sut.originHeader == "http://192.168.1.5:3000")
}
@Test("originHeaderhttps + 非标端口保留端口")
func originHeaderKeepsNonStandardHTTPSPort() throws {
let sut = try #require(endpoint("https://mac.example:8443"))
#expect(sut.originHeader == "https://mac.example:8443")
}
@Test("originHeaderhttps + 443 → 无端口后缀(浏览器 Origin 序列化)")
func originHeaderOmitsDefaultHTTPSPort() throws {
let sut = try #require(endpoint("https://mac.example:443"))
#expect(sut.originHeader == "https://mac.example")
}
@Test("originHeaderhttp + 80 → 无端口后缀")
func originHeaderOmitsDefaultHTTPPort() throws {
let sut = try #require(endpoint("http://mac.example:80"))
#expect(sut.originHeader == "http://mac.example")
}
@Test("originHeader无端口 URL → 无端口后缀localhost 保留非默认端口")
func originHeaderWithoutExplicitPort() throws {
let bare = try #require(endpoint("https://mac.tailnet-1234.ts.net"))
#expect(bare.originHeader == "https://mac.tailnet-1234.ts.net")
let localhost = try #require(endpoint("http://localhost:3000"))
#expect(localhost.originHeader == "http://localhost:3000")
}
@Test("originHeaderscheme 与 host 小写规范化(服务器两侧 new URL() 规范化对称)")
func originHeaderLowercasesSchemeAndHost() throws {
let sut = try #require(endpoint("HTTP://Mac.Example:3000"))
#expect(sut.originHeader == "http://mac.example:3000")
}
@Test("originHeaderbaseURL 的 path/query 不进入 Origin")
func originHeaderIgnoresPathAndQuery() throws {
let sut = try #require(endpoint("http://192.168.1.5:3000/index.html?x=1"))
#expect(sut.originHeader == "http://192.168.1.5:3000")
}
@Test("originHeaderIPv6 host 保留方括号")
func originHeaderBracketsIPv6Host() throws {
let sut = try #require(endpoint("http://[fe80::1]:3000"))
#expect(sut.originHeader == "http://[fe80::1]:3000")
}
// MARK: - wsURL scheme httpws / httpswss + WireConstants.wsPath
@Test("wsURLhttp → ws 同 host 同 port + /term")
func wsURLDerivesWsFromHttp() throws {
let sut = try #require(endpoint("http://192.168.1.5:3000"))
#expect(sut.wsURL.absoluteString == "ws://192.168.1.5:3000/term")
}
@Test("wsURLhttps → wss + /term非标端口保留")
func wsURLDerivesWssFromHttps() throws {
let sut = try #require(endpoint("https://mac.example:8443"))
#expect(sut.wsURL.absoluteString == "wss://mac.example:8443/term")
}
@Test("wsURL无端口 https → wss 无端口path 恒为 WireConstants.wsPath")
func wsURLWithoutPort() throws {
let sut = try #require(endpoint("https://mac.tailnet-1234.ts.net"))
#expect(sut.wsURL.absoluteString == "wss://mac.tailnet-1234.ts.net/term")
#expect(sut.wsURL.path == WireConstants.wsPath)
}
@Test("wsURLbaseURL 带尾斜杠/path/query 时仍只保留 /term")
func wsURLReplacesPathAndDropsQuery() throws {
let sut = try #require(endpoint("http://192.168.1.5:3000/launcher?join=abc"))
#expect(sut.wsURL.absoluteString == "ws://192.168.1.5:3000/term")
}
// MARK: - http(s) / host plan §5
@Test("init非 http(s) scheme 或无 host → nil",
arguments: ["ftp://mac.example", "file:///tmp/x", "mailto:hi@example.com", "http://", "ws://mac.example:3000"])
func initRejectsNonHTTPBaseURLs(urlString: String) {
#expect(endpoint(urlString) == nil)
}
// MARK: - CodableHostRegistry Keychain
@Test("Codable roundtripencode→decode 恒等")
func codableRoundtripPreservesEquality() throws {
// Arrange
let original = try #require(endpoint("https://mac.example:8443"))
// Act
let data = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(HostEndpoint.self, from: data)
// Assert
#expect(decoded == original)
#expect(decoded.originHeader == original.originHeader)
#expect(decoded.wsURL == original.wsURL)
}
@Test("Codable持久化数据被篡改成非 http(s) URL → decode 显式 throw边界再验证")
func codableDecodeRevalidatesBaseURL() {
// Arrange
let tampered = Data("{\"baseURL\":\"ftp://mac.example\"}".utf8)
// Act / Assert
#expect(throws: DecodingError.self) {
_ = try JSONDecoder().decode(HostEndpoint.self, from: tampered)
}
}

View File

@@ -0,0 +1,11 @@
import Testing
import WireProtocol
@Test("脚手架:WireProtocol 包可编译、可导入")
func wireProtocolPackageIsImportable() {
// Arrange / Act
let name = WireProtocolPackage.packageName
// Assert
#expect(name == "WireProtocol")
}

View File

@@ -0,0 +1,259 @@
import Foundation
import Testing
import WireProtocol
// T-iOS-3 · test/protocol.test.ts + decodeServer
// TS/Swift T-iOS-16 CI
private let validUUID = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
// MARK: - Validation.isValidSessionIdSESSION_ID_RE , test/protocol.test.ts:19-49
@Test("isValidSessionId合法 UUID v4 通过")
func sessionIdAcceptsValidUUIDv4() {
#expect(Validation.isValidSessionId(validUUID))
}
@Test("isValidSessionId大写十六进制也通过服务器正则 /i")
func sessionIdAcceptsUppercaseHex() {
#expect(Validation.isValidSessionId("F47AC10B-58CC-4372-A567-0E02B2C3D479"))
}
@Test("isValidSessionIdvariant=8 的 UUID v4 通过")
func sessionIdAcceptsVariant8() {
#expect(Validation.isValidSessionId("550e8400-e29b-41d4-8716-446655440000"))
}
@Test("isValidSessionId非 UUID / 空串 / v1 / 错误 variant 全部拒绝M7",
arguments: [
"abc123",
"",
"550e8400-e29b-11d4-a716-446655440000", // v1=1
"f47ac10b-58cc-4372-c567-0e02b2c3d479", // variant 'c' 8/9/a/b
"f47ac10b58cc4372a5670e02b2c3d479", //
"f47ac10b-58cc-4372-a567-0e02b2c3d47", //
"g47ac10b-58cc-4372-a567-0e02b2c3d479", //
])
func sessionIdRejectsInvalid(candidate: String) {
#expect(!Validation.isValidSessionId(candidate))
}
@Test("isValidSessionId 与测试侧服务器正则镜像逐向量同判(防移植走样)")
func sessionIdAgreesWithServerRegexMirror() {
// Arrange
let candidates = [
validUUID, "F47AC10B-58CC-4372-A567-0E02B2C3D479",
"550e8400-e29b-41d4-8716-446655440000", "abc123", "",
"550e8400-e29b-11d4-a716-446655440000",
"f47ac10b-58cc-4372-c567-0e02b2c3d479",
]
for candidate in candidates {
// Act / Assert
#expect(Validation.isValidSessionId(candidate)
== ServerViewParser.isServerValidSessionId(candidate),
"分歧向量: \(candidate)")
}
}
// MARK: - isValidResize / isAbsoluteCwdsrc/protocol.ts:113-115,142-149
@Test("isValidResize边界 1/1000 通过0/1001 拒绝")
func resizeValidationBoundaries() {
#expect(Validation.isValidResize(cols: 1, rows: 1))
#expect(Validation.isValidResize(cols: 1000, rows: 1000))
#expect(Validation.isValidResize(cols: 120, rows: 40))
#expect(!Validation.isValidResize(cols: 0, rows: 40))
#expect(!Validation.isValidResize(cols: 1001, rows: 40))
#expect(!Validation.isValidResize(cols: 80, rows: 0))
#expect(!Validation.isValidResize(cols: 80, rows: 1001))
}
@Test("isAbsoluteCwd'/a' 通过;'a' 与空串拒绝")
func absoluteCwdValidation() {
#expect(Validation.isAbsoluteCwd("/a"))
#expect(!Validation.isAbsoluteCwd("a"))
#expect(!Validation.isAbsoluteCwd(""))
}
// MARK: - parseClientMessage transcribed from test/protocol.test.ts
@Test("服务器视角镜像合法客户端帧向量全部接受test/protocol.test.ts:53-124")
func serverMirrorAcceptsValidClientVectors() {
#expect(ServerViewParser.parse("{\"type\":\"attach\",\"sessionId\":null}")
== .attach(sessionId: nil, cwd: nil))
#expect(ServerViewParser.parse("{\"type\":\"attach\",\"sessionId\":\"\(validUUID)\"}")
== .attach(sessionId: validUUID, cwd: nil))
#expect(ServerViewParser.parse("{\"type\":\"input\",\"data\":\"ls -la\\r\"}")
== .input(data: "ls -la\r"))
#expect(ServerViewParser.parse("{\"type\":\"resize\",\"cols\":1,\"rows\":1}")
== .resize(cols: 1, rows: 1))
#expect(ServerViewParser.parse("{\"type\":\"resize\",\"cols\":1000,\"rows\":1000}")
== .resize(cols: 1000, rows: 1000))
#expect(ServerViewParser.parse("{\"type\":\"approve\"}") == .approve)
#expect(ServerViewParser.parse("{\"type\":\"reject\"}") == .reject)
// blur test/protocol.test.ts:120-123
#expect(ServerViewParser.parse("{\"type\":\"blur\"}") == nil)
// test/protocol.test.ts:284
#expect(ServerViewParser.parse("{\"type\":\"resize\",\"cols\":80,\"rows\":40,\"extra\":\"ignored\"}")
== .resize(cols: 80, rows: 40))
}
@Test("服务器视角镜像非法客户端帧向量全部拒绝test/protocol.test.ts:128-266",
arguments: [
"not json at all", "", "null", "[]", "42",
"{\"type\":\"ping\"}", "{\"data\":\"hello\"}", "{\"type\":42}",
"{\"type\":\"resize\",\"cols\":0,\"rows\":40}",
"{\"type\":\"resize\",\"cols\":1001,\"rows\":40}",
"{\"type\":\"resize\",\"cols\":80,\"rows\":0}",
"{\"type\":\"resize\",\"cols\":80,\"rows\":1001}",
"{\"type\":\"resize\",\"cols\":80.5,\"rows\":40}",
"{\"type\":\"resize\",\"cols\":80,\"rows\":24.9}",
"{\"type\":\"resize\",\"cols\":\"80\",\"rows\":40}",
"{\"type\":\"resize\",\"rows\":40}",
"{\"type\":\"resize\",\"cols\":80}",
"{\"type\":\"input\",\"data\":42}",
"{\"type\":\"input\",\"data\":null}",
"{\"type\":\"input\"}",
"{\"type\":\"input\",\"data\":{}}",
"{\"type\":\"attach\",\"sessionId\":\"abc123\"}",
"{\"type\":\"attach\",\"sessionId\":\"\"}",
"{\"type\":\"attach\",\"sessionId\":42}",
"{\"type\":\"attach\"}",
"{\"type\":\"attach\",\"sessionId\":null,\"cwd\":\"relative\"}",
"{\"type\":\"attach\",\"sessionId\":null,\"cwd\":42}",
])
func serverMirrorRejectsInvalidClientVectors(frame: String) {
#expect(ServerViewParser.parse(frame) == nil)
}
// MARK: - decodeServer 5 case
@Test("decodeServer(attached):合法 UUID → .attached大写 UUID 同样接受")
func decodeAttachedFrames() {
// Arrange
let expected = UUID(uuidString: validUUID)!
// Act / Assert
#expect(MessageCodec.decodeServer("{\"type\":\"attached\",\"sessionId\":\"\(validUUID)\"}")
== .attached(sessionId: expected))
#expect(MessageCodec.decodeServer(
"{\"type\":\"attached\",\"sessionId\":\"F47AC10B-58CC-4372-A567-0E02B2C3D479\"}")
== .attached(sessionId: expected))
}
@Test("decodeServer(output)ANSI 字节原样进 dataserialize 向量, test/protocol.test.ts:303-307")
func decodeOutputFrame() {
#expect(MessageCodec.decodeServer("{\"type\":\"output\",\"data\":\"\\u001b[1;32mHello\\u001b[0m\"}")
== .output(data: "\u{1B}[1;32mHello\u{1B}[0m"))
}
@Test("decodeServer(exit):仅 code / code+reason 两形状(-1 = spawn 失败)")
func decodeExitFrames() {
#expect(MessageCodec.decodeServer("{\"type\":\"exit\",\"code\":0}")
== .exit(code: 0, reason: nil))
#expect(MessageCodec.decodeServer(
"{\"type\":\"exit\",\"code\":-1,\"reason\":\"spawn failed: /bin/badshell\"}")
== .exit(code: WireConstants.spawnFailedExitCode, reason: "spawn failed: /bin/badshell"))
}
@Test("decodeServer(status)5 个 ClaudeStatus 值全解;缺省 detail/pending/gate 取安全默认",
arguments: ["working", "waiting", "idle", "unknown", "stuck"])
func decodeStatusAllClaudeStatuses(raw: String) throws {
// Arrange / Act
let message = MessageCodec.decodeServer("{\"type\":\"status\",\"status\":\"\(raw)\"}")
// Assert
let expectedStatus = try #require(ClaudeStatus(rawValue: raw))
#expect(message == .status(expectedStatus, detail: nil, pending: false, gate: nil))
}
@Test("decodeServer(status)pending/gate/detail 全携带tool 与 plan 两种 gate")
func decodeStatusWithGate() {
#expect(MessageCodec.decodeServer(
"{\"type\":\"status\",\"status\":\"waiting\",\"detail\":\"Bash\",\"pending\":true,\"gate\":\"tool\"}")
== .status(.waiting, detail: "Bash", pending: true, gate: .tool))
#expect(MessageCodec.decodeServer(
"{\"type\":\"status\",\"status\":\"waiting\",\"pending\":true,\"gate\":\"plan\"}")
== .status(.waiting, detail: nil, pending: true, gate: .plan))
}
@Test("decodeServer(status):未知 gate 值按缺席容忍(保留 pending 信号,不丢帧)")
func decodeStatusToleratesUnknownGate() {
#expect(MessageCodec.decodeServer(
"{\"type\":\"status\",\"status\":\"waiting\",\"pending\":true,\"gate\":\"alien\"}")
== .status(.waiting, detail: nil, pending: true, gate: nil))
// pending bool false
#expect(MessageCodec.decodeServer(
"{\"type\":\"status\",\"status\":\"working\",\"pending\":\"yes\"}")
== .status(.working, detail: nil, pending: false, gate: nil))
}
@Test("decodeServer(telemetry)全字段样本逐字段解出src/types.ts:406-416 镜像)")
func decodeTelemetryFullSample() throws {
// Arrange
let frame = """
{"type":"telemetry","telemetry":{"contextUsedPct":42.5,"costUsd":1.23,\
"linesAdded":10,"linesRemoved":2,"model":"Fable 5","effort":"high",\
"pr":{"number":7,"url":"https://github.com/x/y/pull/7","reviewState":"APPROVED"},\
"rate":{"fiveHourPct":12.5,"sevenDayPct":33},"at":1751600000000}}
"""
// Act
let message = try #require(MessageCodec.decodeServer(frame))
// Assert
let expected = StatusTelemetry(
contextUsedPct: 42.5, costUsd: 1.23, linesAdded: 10, linesRemoved: 2,
model: "Fable 5", effort: "high",
pr: PrInfo(number: 7, url: "https://github.com/x/y/pull/7", reviewState: "APPROVED"),
rate: RateInfo(fiveHourPct: 12.5, sevenDayPct: 33),
at: 1_751_600_000_000
)
#expect(message == .telemetry(expected))
}
@Test("decodeServer(telemetry):全可选字段缺省仍可解(只有 at")
func decodeTelemetryMinimalSample() {
#expect(MessageCodec.decodeServer("{\"type\":\"telemetry\",\"telemetry\":{\"at\":123}}")
== .telemetry(StatusTelemetry(at: 123)))
}
@Test("decodeServer(telemetry):可选字段类型错误按缺席容忍(帧保留)")
func decodeTelemetryToleratesWrongTypedOptionalField() {
#expect(MessageCodec.decodeServer(
"{\"type\":\"telemetry\",\"telemetry\":{\"at\":5,\"costUsd\":\"lots\",\"pr\":42}}")
== .telemetry(StatusTelemetry(at: 5)))
}
// MARK: - decodeServer nil throw
@Test("decodeServer非法帧全表 → nil坏 JSON/未知 type/字段缺失或错型/at 缺失)",
arguments: [
"not json at all", "", "null", "[]", "42", "true",
"{\"type\":\"ping\"}", "{\"data\":\"hello\"}", "{\"type\":42}",
// attached UUID / v1 / / M7
"{\"type\":\"attached\",\"sessionId\":\"abc123\"}",
"{\"type\":\"attached\",\"sessionId\":\"550e8400-e29b-11d4-a716-446655440000\"}",
"{\"type\":\"attached\"}",
"{\"type\":\"attached\",\"sessionId\":42}",
// outputdata /
"{\"type\":\"output\"}",
"{\"type\":\"output\",\"data\":42}",
// exitcode / /
"{\"type\":\"exit\"}",
"{\"type\":\"exit\",\"code\":\"0\"}",
"{\"type\":\"exit\",\"code\":1.5}",
// statusstatus / /
"{\"type\":\"status\"}",
"{\"type\":\"status\",\"status\":\"sleeping\"}",
"{\"type\":\"status\",\"status\":42}",
// telemetrytelemetry / / at / at
"{\"type\":\"telemetry\"}",
"{\"type\":\"telemetry\",\"telemetry\":\"x\"}",
"{\"type\":\"telemetry\",\"telemetry\":{\"costUsd\":1}}",
"{\"type\":\"telemetry\",\"telemetry\":{\"at\":\"now\"}}",
])
func decodeServerReturnsNilForMalformedFrames(frame: String) {
#expect(MessageCodec.decodeServer(frame) == nil)
}

View File

@@ -0,0 +1,110 @@
import Foundation
/// Test-side mini-port of the server's `parseClientMessage` (src/protocol.ts:45-176)
/// plus `parseApproveMode` (src/server.ts:94-102). Used by the roundtrip property
/// test and the cross-implementation vector tests: `MessageCodec.encode` output is
/// fed through THIS parser, which itself is pinned against the vectors transcribed
/// from test/protocol.test.ts so the Swift encoder and the TS server parser
/// cannot drift silently (the live tripwire is T-iOS-16's real-server CI).
enum ServerViewMessage: Equatable {
case attach(sessionId: String?, cwd: String?)
case input(data: String)
case resize(cols: Int, rows: Int)
case approve
case reject
}
enum ServerViewParser {
/// Mirror of src/protocol.ts SESSION_ID_RE (UUID v4, /i).
private static let sessionIdPattern =
"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
/// Mirror of src/server.ts:76 PERMISSION_MODES.
static let permissionModes: Set<String> = ["default", "acceptEdits", "plan", "auto"]
/// Mirror of src/protocol.ts:27 ALLOWED_TYPES.
private static let allowedTypes: Set<String> = ["attach", "input", "resize", "approve", "reject"]
// MARK: - parseClientMessage mirror (never throws; invalid nil)
static func parse(_ raw: String) -> ServerViewMessage? {
guard let data = raw.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]),
let obj = parsed as? [String: Any],
let type = obj["type"] as? String,
allowedTypes.contains(type)
else { return nil }
switch type {
case "resize": return parseResize(obj)
case "input": return parseInput(obj)
case "approve": return .approve
case "reject": return .reject
default: return parseAttach(obj)
}
}
/// Mirror of src/server.ts:94-102 reads `mode` from the RAW frame's top level.
static func topLevelMode(_ raw: String) -> String? {
guard let data = raw.data(using: .utf8),
let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let mode = obj["mode"] as? String,
permissionModes.contains(mode)
else { return nil }
return mode
}
// MARK: - Per-type validators (src/protocol.ts:90-176)
private static func parseResize(_ obj: [String: Any]) -> ServerViewMessage? {
guard let cols = integerDimension(obj["cols"]),
let rows = integerDimension(obj["rows"])
else { return nil }
return .resize(cols: cols, rows: rows)
}
/// Mirror of isValidDimension: number, integer, 1...1000 (src/protocol.ts:113-115).
private static func integerDimension(_ value: Any?) -> Int? {
guard let number = value as? NSNumber, !isJSONBoolean(number) else { return nil }
let doubleValue = number.doubleValue
guard doubleValue == doubleValue.rounded(.towardZero),
doubleValue >= 1, doubleValue <= 1000
else { return nil }
return number.intValue
}
private static func isJSONBoolean(_ number: NSNumber) -> Bool {
CFGetTypeID(number) == CFBooleanGetTypeID()
}
private static func parseInput(_ obj: [String: Any]) -> ServerViewMessage? {
guard let data = obj["data"] as? String else { return nil }
return .input(data: data)
}
private static func parseAttach(_ obj: [String: Any]) -> ServerViewMessage? {
// sessionId key must be explicitly present (src/protocol.ts:132-134).
guard obj.index(forKey: "sessionId") != nil else { return nil }
var cwd: String?
if let rawCwd = obj["cwd"] {
guard let cwdString = rawCwd as? String, cwdString.hasPrefix("/") else { return nil }
cwd = cwdString
}
if obj["sessionId"] is NSNull {
return .attach(sessionId: nil, cwd: cwd)
}
guard let sessionId = obj["sessionId"] as? String, isServerValidSessionId(sessionId) else {
return nil
}
return .attach(sessionId: sessionId, cwd: cwd)
}
static func isServerValidSessionId(_ candidate: String) -> Bool {
candidate.range(
of: sessionIdPattern,
options: [.regularExpression, .caseInsensitive]
) != nil
}
}

View File

@@ -0,0 +1,71 @@
import Foundation
import WireProtocol
/// Deterministic RNG for the roundtrip property test (reproducible failures:
/// the seed is baked into the test; change it only with a note in the test).
struct SplitMix64: RandomNumberGenerator {
private var state: UInt64
init(seed: UInt64) {
state = seed
}
mutating func next() -> UInt64 {
state &+= 0x9E37_79B9_7F4A_7C15
var z = state
z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
return z ^ (z >> 31)
}
}
enum MessageGen {
/// Character pool that stresses the JSON escaper: quotes, backslashes,
/// C0 control bytes (ESC / CR / TAB / ^C / NUL), CJK, emoji, plain ASCII.
private static let stressPool: [Character] = [
"a", "Z", "0", " ", "\"", "\\", "/", "{", "}",
"\u{1B}", "\r", "\n", "\t", "\u{03}", "\u{00}", "\u{7F}",
"", "", "", "", "🚀", "🧪", "é", "ß",
]
static func randomString(using rng: inout some RandomNumberGenerator, maxLength: Int = 48) -> String {
let length = Int.random(in: 0...maxLength, using: &rng)
return String((0..<length).map { _ in stressPool.randomElement(using: &rng)! })
}
static func randomAbsolutePath(using rng: inout some RandomNumberGenerator) -> String {
"/" + randomString(using: &rng, maxLength: 24)
}
/// Seeded UUID v4 (version nibble 4, variant 10xx) passes SESSION_ID_RE.
static func randomUUIDv4(using rng: inout some RandomNumberGenerator) -> UUID {
var bytes = (0..<16).map { _ in UInt8.random(in: 0...255, using: &rng) }
bytes[6] = (bytes[6] & 0x0F) | 0x40
bytes[8] = (bytes[8] & 0x3F) | 0x80
return UUID(uuid: (
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
))
}
static func randomClientMessage(using rng: inout some RandomNumberGenerator) -> ClientMessage {
switch Int.random(in: 0...4, using: &rng) {
case 0:
let sessionId = Bool.random(using: &rng) ? randomUUIDv4(using: &rng) : nil
let cwd = Bool.random(using: &rng) ? randomAbsolutePath(using: &rng) : nil
return .attach(sessionId: sessionId, cwd: cwd)
case 1:
return .input(data: randomString(using: &rng))
case 2:
return .resize(
cols: Int.random(in: 1...1000, using: &rng),
rows: Int.random(in: 1...1000, using: &rng)
)
case 3:
let mode = Bool.random(using: &rng) ? ApproveMode.allCases.randomElement(using: &rng) : nil
return .approve(mode: mode)
default:
return .reject
}
}
}

View File

@@ -0,0 +1,77 @@
import Foundation
import Testing
import WireProtocol
// T-iOS-3 · TimelineEventGET /live-sessions/:id/events src/types.ts:428-433
// decodeList throw/ class
@Test("TimelineEvent合法条目全字段解码")
func decodesValidEntryWithAllFields() throws {
// Arrange
let json = Data("{\"at\":1751600000000,\"class\":\"tool\",\"toolName\":\"Bash\",\"label\":\"ran Bash\"}".utf8)
// Act
let event = try JSONDecoder().decode(TimelineEvent.self, from: json)
// Assert
#expect(event == TimelineEvent(at: 1_751_600_000_000, class: "tool", toolName: "Bash", label: "ran Bash"))
#expect(event.hasKnownClass)
}
@Test("TimelineEventtoolName 可选缺省")
func decodesEntryWithoutToolName() throws {
// Arrange
let json = Data("{\"at\":1,\"class\":\"waiting\",\"label\":\"waiting for approval\"}".utf8)
// Act
let event = try JSONDecoder().decode(TimelineEvent.self, from: json)
// Assert
#expect(event.toolName == nil)
#expect(event.hasKnownClass)
}
@Test("knownClasses 与服务器 TimelineClass 全集一致src/types.ts:423")
func knownClassesMirrorServerUnion() {
#expect(TimelineEvent.knownClasses == ["tool", "waiting", "done", "stuck", "user"])
}
@Test("decodeList未知 class 条目被丢弃,合法条目保留(消费方丢弃语义)")
func decodeListDropsUnknownClassEntries() {
// Arrange 1 + 1 class + 1 label + 1
let json = Data("""
[{"at":1,"class":"tool","label":"ran Bash"},
{"at":2,"class":"alien","label":"???"},
{"at":3,"class":"done"},
42]
""".utf8)
// Act
let events = TimelineEvent.decodeList(from: json)
// Assert
#expect(events == [TimelineEvent(at: 1, class: "tool", toolName: nil, label: "ran Bash")])
}
@Test("decodeList5 个已知 class 全部保留")
func decodeListKeepsAllKnownClasses() {
// Arrange
let json = Data("""
[{"at":1,"class":"tool","label":"a"},{"at":2,"class":"waiting","label":"b"},
{"at":3,"class":"done","label":"c"},{"at":4,"class":"stuck","label":"d"},
{"at":5,"class":"user","label":"e"}]
""".utf8)
// Act
let events = TimelineEvent.decodeList(from: json)
// Assert
#expect(events.count == 5)
#expect(events.allSatisfy { $0.hasKnownClass })
}
@Test("decodeList坏 JSON / 顶层非数组 → 空数组,永不 throw",
arguments: ["not json", "{\"at\":1}", "42", ""])
func decodeListNeverThrowsOnMalformedInput(text: String) {
#expect(TimelineEvent.decodeList(from: Data(text.utf8)) == [])
}