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:
@@ -0,0 +1,215 @@
|
||||
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]
|
||||
}
|
||||
@@ -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("WireConstants:wsPath/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")
|
||||
}
|
||||
@@ -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("originHeader:http + 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("originHeader:https + 非标端口保留端口")
|
||||
func originHeaderKeepsNonStandardHTTPSPort() throws {
|
||||
let sut = try #require(endpoint("https://mac.example:8443"))
|
||||
#expect(sut.originHeader == "https://mac.example:8443")
|
||||
}
|
||||
|
||||
@Test("originHeader:https + 443 → 无端口后缀(浏览器 Origin 序列化)")
|
||||
func originHeaderOmitsDefaultHTTPSPort() throws {
|
||||
let sut = try #require(endpoint("https://mac.example:443"))
|
||||
#expect(sut.originHeader == "https://mac.example")
|
||||
}
|
||||
|
||||
@Test("originHeader:http + 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("originHeader:scheme 与 host 小写规范化(服务器两侧 new URL() 规范化对称)")
|
||||
func originHeaderLowercasesSchemeAndHost() throws {
|
||||
let sut = try #require(endpoint("HTTP://Mac.Example:3000"))
|
||||
#expect(sut.originHeader == "http://mac.example:3000")
|
||||
}
|
||||
|
||||
@Test("originHeader:baseURL 的 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("originHeader:IPv6 host 保留方括号")
|
||||
func originHeaderBracketsIPv6Host() throws {
|
||||
let sut = try #require(endpoint("http://[fe80::1]:3000"))
|
||||
#expect(sut.originHeader == "http://[fe80::1]:3000")
|
||||
}
|
||||
|
||||
// MARK: - wsURL 派生向量(scheme http→ws / https→wss + WireConstants.wsPath)
|
||||
|
||||
@Test("wsURL:http → 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("wsURL:https → 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("wsURL:baseURL 带尾斜杠/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: - Codable(HostRegistry Keychain 持久化经由它)
|
||||
|
||||
@Test("Codable roundtrip:encode→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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Testing
|
||||
import WireProtocol
|
||||
|
||||
@Test("脚手架:WireProtocol 包可编译、可导入")
|
||||
func wireProtocolPackageIsImportable() {
|
||||
// Arrange / Act
|
||||
let name = WireProtocolPackage.packageName
|
||||
|
||||
// Assert
|
||||
#expect(name == "WireProtocol")
|
||||
}
|
||||
@@ -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.isValidSessionId(SESSION_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("isValidSessionId:variant=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 / isAbsoluteCwd(src/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 字节原样进 data(serialize 向量, 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}",
|
||||
// output:data 缺失 / 错型
|
||||
"{\"type\":\"output\"}",
|
||||
"{\"type\":\"output\",\"data\":42}",
|
||||
// exit:code 缺失 / 字符串 / 非整数
|
||||
"{\"type\":\"exit\"}",
|
||||
"{\"type\":\"exit\",\"code\":\"0\"}",
|
||||
"{\"type\":\"exit\",\"code\":1.5}",
|
||||
// status:status 缺失 / 未知值 / 错型(白名单)
|
||||
"{\"type\":\"status\"}",
|
||||
"{\"type\":\"status\",\"status\":\"sleeping\"}",
|
||||
"{\"type\":\"status\",\"status\":42}",
|
||||
// telemetry:telemetry 键缺失 / 非对象 / 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import WireProtocol
|
||||
|
||||
// T-iOS-3 · TimelineEvent(GET /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("TimelineEvent:toolName 可选缺省")
|
||||
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("decodeList:5 个已知 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)) == [])
|
||||
}
|
||||
Reference in New Issue
Block a user