feat(ios): W3 UI layer + integration CI + ntfy docs
T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM) T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field) T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed) T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites) Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
This commit is contained in:
281
ios/IntegrationTests/WSTestClient.swift
Normal file
281
ios/IntegrationTests/WSTestClient.swift
Normal file
@@ -0,0 +1,281 @@
|
||||
//
|
||||
// WSTestClient.swift — T-iOS-16 集成测试的 WS 客户端与协议流程助手
|
||||
//
|
||||
// 由 OriginSpikeTests.swift 拆分而来。与 spike 版的关键差异:帧的编/解码一律走
|
||||
// 冻结契约 `MessageCodec`(T-iOS-3)——集成测试因此同时守护"客户端复刻的协议"
|
||||
// 与服务器实现之间的漂移(plan §9:这是 Origin spike 的常驻化)。
|
||||
// 默认 `maximumMessageSize = Tunables.maxWSMessageBytes`(16 MiB,plan §3.2.1);
|
||||
// 只有 spike③ 的复现分支显式传 nil 保持平台默认 1 MiB。
|
||||
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
// ─── WS 客户端薄封装(每测一个;不可变持有 task) ───────────────────────────
|
||||
|
||||
final class WSTestClient: @unchecked Sendable {
|
||||
let task: URLSessionWebSocketTask
|
||||
|
||||
/// - Parameters:
|
||||
/// - origin: nil = 不带 Origin header(守卫负路径)。带值时必须来自
|
||||
/// `TestServer.origin`(HostEndpoint 单点派生)或刻意构造的失配值。
|
||||
/// - maxMessageBytes: 默认 `Tunables.maxWSMessageBytes`(16 MiB);
|
||||
/// nil = 保持平台默认 1 MiB(仅 spike③ 复现分支)。
|
||||
init(server: TestServer, origin: String?, maxMessageBytes: Int? = Tunables.maxWSMessageBytes) {
|
||||
var request = URLRequest(url: server.wsURL)
|
||||
request.timeoutInterval = 15
|
||||
if let origin {
|
||||
// T-iOS-2 已定案的平台事实:Origin 不在 reserved-header 列表,setValue 生效。
|
||||
request.setValue(origin, forHTTPHeaderField: "Origin")
|
||||
}
|
||||
let task = URLSession.shared.webSocketTask(with: request)
|
||||
if let maxMessageBytes { task.maximumMessageSize = maxMessageBytes }
|
||||
self.task = task
|
||||
task.resume()
|
||||
}
|
||||
|
||||
/// 握手响应状态码(升级被拒时应为 401;成功时 101)。
|
||||
var handshakeStatusCode: Int? {
|
||||
(task.response as? HTTPURLResponse)?.statusCode
|
||||
}
|
||||
|
||||
/// 经冻结契约编码后发送(客户端帧永远走 MessageCodec,不手拼 JSON)。
|
||||
func send(_ message: ClientMessage) async throws {
|
||||
try await task.send(.string(MessageCodec.encode(message)))
|
||||
}
|
||||
|
||||
/// 收下一条文本帧;binary 帧防御性丢弃(服务器只发文本,但不信任它)。
|
||||
func receiveText(timeout: Duration) async throws -> String {
|
||||
try await withTimeout(timeout) { [self] in
|
||||
while true {
|
||||
switch try await task.receive() {
|
||||
case .string(let text): return text
|
||||
case .data: continue
|
||||
@unknown default: continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 升级失败探测:能收到帧/正常等待 → nil;handshake 抛错 → 返回错误。
|
||||
func handshakeFailure(timeout: Duration) async -> (any Error)? {
|
||||
do {
|
||||
_ = try await receiveText(timeout: timeout)
|
||||
return nil
|
||||
} catch {
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
func close() {
|
||||
task.cancel(with: .normalClosure, reason: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func withTimeout<T: Sendable>(
|
||||
_ timeout: Duration,
|
||||
_ operation: @escaping @Sendable () async throws -> T
|
||||
) async throws -> T {
|
||||
try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask { try await operation() }
|
||||
group.addTask {
|
||||
try await Task.sleep(for: timeout)
|
||||
throw HarnessError.timeout("操作超过 \(timeout)")
|
||||
}
|
||||
guard let first = try await group.next() else {
|
||||
throw HarnessError.timeout("task group 无结果")
|
||||
}
|
||||
group.cancelAll()
|
||||
return first
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 协议流程助手(解码一律 MessageCodec.decodeServer;非法帧丢弃,同服务器语义) ──
|
||||
|
||||
/// input 帧:Enter 是 \r(0x0D),不是 \n(CLAUDE.md gotcha)。
|
||||
func shellCommand(_ line: String) -> ClientMessage {
|
||||
.input(data: line + "\r")
|
||||
}
|
||||
|
||||
/// attach 后等 attached 帧;容忍先到的 output/status(attachWs 在 attached
|
||||
/// 之前就回放 snapshot,src/session/session.ts:158-170 / src/server.ts:721-733)。
|
||||
func attachAndAwaitAttached(client: WSTestClient, sessionId: UUID?) async throws -> UUID {
|
||||
try await client.send(.attach(sessionId: sessionId, cwd: nil))
|
||||
let deadline = ContinuousClock.now + HarnessTunables.frameTimeout
|
||||
while ContinuousClock.now < deadline {
|
||||
let text = try await client.receiveText(timeout: HarnessTunables.frameTimeout)
|
||||
if case let .attached(id)? = MessageCodec.decodeServer(text) { return id }
|
||||
}
|
||||
throw HarnessError.timeout("attach 后未收到 attached 帧")
|
||||
}
|
||||
|
||||
/// 累计 output 帧的原始字节数(UTF-8),直到 ≥ minBytes。
|
||||
func accumulateOutputBytes(client: WSTestClient, minBytes: Int) async throws -> Int {
|
||||
var total = 0
|
||||
let deadline = ContinuousClock.now + HarnessTunables.outputAccumulationTimeout
|
||||
while ContinuousClock.now < deadline {
|
||||
let text = try await client.receiveText(timeout: HarnessTunables.outputAccumulationTimeout)
|
||||
guard case let .output(data)? = MessageCodec.decodeServer(text) else { continue }
|
||||
total += data.utf8.count
|
||||
if total >= minBytes { return total }
|
||||
}
|
||||
throw HarnessError.timeout("输出累计 \(total)/\(minBytes) 字节后超时")
|
||||
}
|
||||
|
||||
/// 顺序累积 output 数据、等待标记出现(标记可跨帧,故必须整段累积再查找)。
|
||||
/// 值类型:每个测试自己持有一份,随用随弃(不可变风格;transcript 只增不改)。
|
||||
struct TranscriptReader {
|
||||
let client: WSTestClient
|
||||
private(set) var transcript = ""
|
||||
|
||||
mutating func awaitContains(
|
||||
_ marker: String, timeout: Duration = HarnessTunables.markerTimeout
|
||||
) async throws {
|
||||
let deadline = ContinuousClock.now + timeout
|
||||
while !transcript.contains(marker) {
|
||||
guard ContinuousClock.now < deadline else {
|
||||
throw HarnessError.timeout(
|
||||
"等 \"\(marker)\" 超时;transcript 尾部: …\(transcript.suffix(200))")
|
||||
}
|
||||
let text = try await client.receiveText(timeout: timeout)
|
||||
if case let .output(data)? = MessageCodec.decodeServer(text) {
|
||||
transcript += data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ReplaySummary: Sendable {
|
||||
let adoptedSessionId: UUID
|
||||
let totalOutputBytes: Int
|
||||
let containsSoftReset: Bool
|
||||
}
|
||||
|
||||
/// reattach 并接收全量回放。成功条件:收到 attached 且 output ≥ minBytes。
|
||||
func replayResult(
|
||||
client: WSTestClient, sessionId: UUID, minBytes: Int
|
||||
) async -> Result<ReplaySummary, any Error> {
|
||||
do {
|
||||
try await client.send(.attach(sessionId: sessionId, cwd: nil))
|
||||
var adopted: UUID?
|
||||
var total = 0
|
||||
var sawSoftReset = false
|
||||
let deadline = ContinuousClock.now + HarnessTunables.outputAccumulationTimeout
|
||||
while ContinuousClock.now < deadline {
|
||||
let text = try await client.receiveText(timeout: HarnessTunables.frameTimeout)
|
||||
switch MessageCodec.decodeServer(text) {
|
||||
case let .attached(id)?:
|
||||
adopted = id
|
||||
case let .output(data)?:
|
||||
total += data.utf8.count
|
||||
// 回放前缀 soft-reset \x1b[0m(src/types.ts:167-170 / M2)。
|
||||
if !sawSoftReset, data.contains(WireConstants.replaySoftResetPrefix) {
|
||||
sawSoftReset = true
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
if let adopted, total >= minBytes {
|
||||
return .success(ReplaySummary(
|
||||
adoptedSessionId: adopted, totalOutputBytes: total,
|
||||
containsSoftReset: sawSoftReset))
|
||||
}
|
||||
}
|
||||
return .failure(HarnessError.timeout(
|
||||
"回放不完整: attached=\(adopted.map(\.uuidString) ?? "无") bytes=\(total)/\(minBytes)"))
|
||||
} catch {
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// 读到 socket 结束为止:返回(是否见到 exit 帧,是否观察到 close)。
|
||||
/// 本地等待超时(HarnessError)≠ close —— 二者显式区分,绝不混淆断言语义。
|
||||
func drainUntilClose(
|
||||
client: WSTestClient, timeout: Duration = HarnessTunables.closeObserveTimeout
|
||||
) async -> (sawExit: Bool, closed: Bool) {
|
||||
var sawExit = false
|
||||
let deadline = ContinuousClock.now + timeout
|
||||
while ContinuousClock.now < deadline {
|
||||
do {
|
||||
let text = try await client.receiveText(timeout: timeout)
|
||||
if case .exit? = MessageCodec.decodeServer(text) { sawExit = true }
|
||||
} catch is HarnessError {
|
||||
return (sawExit, false) // 本地超时:socket 还开着
|
||||
} catch {
|
||||
return (sawExit, true) // receive 抛平台错误 = 流已结束(close/断开)
|
||||
}
|
||||
}
|
||||
return (sawExit, false)
|
||||
}
|
||||
|
||||
/// 等 exit 帧(自然退出广播路径,src/session/session.ts:146-151)。
|
||||
func awaitExitFrame(
|
||||
client: WSTestClient, timeout: Duration = HarnessTunables.markerTimeout
|
||||
) async throws -> (code: Int, reason: String?) {
|
||||
let deadline = ContinuousClock.now + timeout
|
||||
while ContinuousClock.now < deadline {
|
||||
let text = try await client.receiveText(timeout: timeout)
|
||||
if case let .exit(code, reason)? = MessageCodec.decodeServer(text) {
|
||||
return (code, reason)
|
||||
}
|
||||
}
|
||||
throw HarnessError.timeout("未收到 exit 帧")
|
||||
}
|
||||
|
||||
// ─── 超限失败模式判定(spike③ 复现分支) ────────────────────────────────────
|
||||
|
||||
/// 超 maximumMessageSize 的失败模式:NSPOSIXErrorDomain 40(Darwin errno 40 =
|
||||
/// EMSGSIZE "Message too long";plan §1 勘误 —— 原文误标 ENOBUFS,Darwin 上
|
||||
/// ENOBUFS 实为 55,两个码都接受)。
|
||||
func isMessageTooLongError(_ error: any Error) -> Bool {
|
||||
func matches(_ ns: NSError) -> Bool {
|
||||
ns.domain == NSPOSIXErrorDomain && (ns.code == Int(EMSGSIZE) || ns.code == Int(ENOBUFS))
|
||||
}
|
||||
let ns = error as NSError
|
||||
if matches(ns) { return true }
|
||||
if let underlying = ns.userInfo[NSUnderlyingErrorKey] as? NSError { return matches(underlying) }
|
||||
return false
|
||||
}
|
||||
|
||||
func describeError(_ error: any Error) -> String {
|
||||
let ns = error as NSError
|
||||
let underlying = (ns.userInfo[NSUnderlyingErrorKey] as? NSError)
|
||||
.map { " underlying=\($0.domain)#\($0.code)" } ?? ""
|
||||
return "\(ns.domain)#\(ns.code)\(underlying): \(ns.localizedDescription)"
|
||||
}
|
||||
|
||||
// ─── HTTP 助手(G 类端点带 Origin;RO GET 一律不带 —— plan §3.4 铁律) ────────
|
||||
|
||||
/// DELETE /live-sessions/:id(G 类,src/server.ts:354-359)。origin=nil 用于
|
||||
/// 守卫负路径(应得 403)。返回 HTTP 状态码。
|
||||
func deleteLiveSession(server: TestServer, id: String, origin: String?) async throws -> Int {
|
||||
var request = URLRequest(url: server.baseURL.appending(path: "live-sessions/\(id)"))
|
||||
request.httpMethod = "DELETE"
|
||||
if let origin { request.setValue(origin, forHTTPHeaderField: "Origin") }
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw HarnessError.setup("DELETE /live-sessions/\(id) 非 HTTP 响应")
|
||||
}
|
||||
return http.statusCode
|
||||
}
|
||||
|
||||
/// GET /live-sessions(RO,无 Origin)→ 当前会话 id 列表。
|
||||
func liveSessionIds(server: TestServer) async throws -> [String] {
|
||||
let url = server.baseURL.appending(path: "live-sessions")
|
||||
let (data, response) = try await URLSession.shared.data(from: url)
|
||||
guard (response as? HTTPURLResponse)?.statusCode == 200,
|
||||
let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
|
||||
else {
|
||||
throw HarnessError.setup("GET /live-sessions 非预期形状")
|
||||
}
|
||||
return array.compactMap { $0["id"] as? String }
|
||||
}
|
||||
|
||||
/// 测后清理:尽力而为 —— 失败只影响宿主残留一个 bash 会话,不影响断言结果,
|
||||
/// 故显式忽略错误(这是唯一允许吞错的地方,且吞的只是清理路径)。
|
||||
func killSessionBestEffort(server: TestServer, id: UUID) async {
|
||||
_ = try? await deleteLiveSession(
|
||||
server: server, id: id.uuidString.lowercased(), origin: server.origin)
|
||||
}
|
||||
Reference in New Issue
Block a user