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,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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user