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,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.isValidSessionIdSESSION_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("isValidSessionIdvariant=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 / isAbsoluteCwdsrc/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 字节原样进 dataserialize 向量, 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}",
// outputdata /
"{\"type\":\"output\"}",
"{\"type\":\"output\",\"data\":42}",
// exitcode / /
"{\"type\":\"exit\"}",
"{\"type\":\"exit\",\"code\":\"0\"}",
"{\"type\":\"exit\",\"code\":1.5}",
// statusstatus / /
"{\"type\":\"status\"}",
"{\"type\":\"status\",\"status\":\"sleeping\"}",
"{\"type\":\"status\",\"status\":42}",
// telemetrytelemetry / / 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)
}