Files
web-terminal/ios/IntegrationTests/WSTestClient.swift
Yaojia Wang 5098643355 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
2026-07-05 00:13:14 +02:00

282 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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)
}