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
260 lines
12 KiB
Swift
260 lines
12 KiB
Swift
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)
|
||
}
|