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,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")
}