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