Files
web-terminal/ios/Packages/WireProtocol/Tests/WireProtocolTests/CodecRoundtripTests.swift
Yaojia Wang cbaa08daba 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
2026-07-04 21:19:30 +02:00

216 lines
8.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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