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:
Yaojia Wang
2026-07-05 00:13:14 +02:00
parent 5c8c87fb7a
commit 5098643355
33 changed files with 5946 additions and 616 deletions

View 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 MiBplan §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
}
}
}
}
/// / nilhandshake
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 \r0x0D \nCLAUDE.md gotcha
func shellCommand(_ line: String) -> ClientMessage {
.input(data: line + "\r")
}
/// attach attached output/statusattachWs attached
/// snapshotsrc/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[0msrc/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 40Darwin errno 40 =
/// EMSGSIZE "Message too long"plan §1 ENOBUFSDarwin
/// 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 OriginRO GET plan §3.4
/// DELETE /live-sessions/:idG src/server.ts:354-359origin=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-sessionsRO 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)
}