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
216 lines
8.5 KiB
Swift
216 lines
8.5 KiB
Swift
import Foundation
|
||
import Testing
|
||
import WireProtocol
|
||
|
||
// T-iOS-3 · MessageCodec.encode 与服务器 parseClientMessage 的逐键契约 + roundtrip
|
||
// property + fuzz。向量出处:src/protocol.ts、src/server.ts:91-102、test/protocol.test.ts。
|
||
|
||
// MARK: - encode 形状(服务器视角逐键一致)
|
||
|
||
@Test("encode(attach) 无 sessionId 时也显式携带 sessionId:null(src/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 property(approve.mode 豁免:服务器刻意丢 mode,src/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: - fuzz:decodeServer 对随机字节永不 crash(安全注:服务器是不可信输入源)
|
||
|
||
@Test("fuzz:300 轮随机字节喂 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]
|
||
}
|