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

@@ -1,13 +0,0 @@
import Testing
import WireProtocol
/// T-iOS-1 scaffold placeholder real integration tests against a live Node
/// server land in T-iOS-2 (OriginSpikeTests) and T-iOS-16 (CI harness).
@Test("脚手架:IntegrationTests 包可编译并能 import WireProtocol")
func integrationTestsScaffoldCompiles() {
// Arrange / Act
let contract = WireProtocolPackage.packageName
// Assert
#expect(contract == "WireProtocol")
}

View File

@@ -0,0 +1,100 @@
//
// MirrorLifecycleTests.swift T-iOS-16 4/6/7JOIN mirror
//
// 4. JOIN mirrorAB attach attach
// src/session/manager.ts:108-174A inputB output
// 6. DELETE /live-sessions/:idkill WS close
// exit manager.killById ws.close() kill PTY
// src/session/manager.ts:202-212exit 广 OPEN socket
// sendIfOpen id GET /live-sessions
// 7. 退广A input "exit\r" bash 退 pty.onExit
// socket 广 {type:'exit'}src/session/session.ts:146-151 B
import Foundation
import Testing
import WireProtocol
@Suite("T-iOS-16 镜像与生命周期(真服务器)", .serialized, .timeLimit(.minutes(5)))
struct MirrorLifecycleTests {
@Test("双客户端 JOIN mirror:B attach 同一会话拿到同 id,A input → B 收 output")
func mirrorJoinSharesOutput() async throws {
// Arrange: A ,B id (, A)
let server = try await ServerHarness.shared.server()
let clientA = WSTestClient(server: server, origin: server.origin)
let clientB = WSTestClient(server: server, origin: server.origin)
defer {
clientA.close()
clientB.close()
}
let sessionId = try await attachAndAwaitAttached(client: clientA, sessionId: nil)
let adoptedByB = try await attachAndAwaitAttached(client: clientB, sessionId: sessionId)
#expect(adoptedByB == sessionId, "镜像 attach 应采用同一会话 id(JOIN 不踢)")
// Act: A $((1300+37)) shell 1337
var readerB = TranscriptReader(client: clientB)
try await clientA.send(shellCommand("echo \"MIR:$((1300+37))\""))
// Assert: B() A 广
try await readerB.awaitContains("MIR:1337")
// Cleanup
clientA.close()
clientB.close()
await killSessionBestEffort(server: server, id: sessionId)
}
@Test("DELETE kill → 镜像观察到 WS close(非 exit 帧),id 从 /live-sessions 消失")
func killClosesMirrorSocketsWithoutExitFrame() async throws {
// Arrange
let server = try await ServerHarness.shared.server()
let clientA = WSTestClient(server: server, origin: server.origin)
let clientB = WSTestClient(server: server, origin: server.origin)
defer {
clientA.close()
clientB.close()
}
let sessionId = try await attachAndAwaitAttached(client: clientA, sessionId: nil)
_ = try await attachAndAwaitAttached(client: clientB, sessionId: sessionId)
let idString = sessionId.uuidString.lowercased()
// Act: Origin kill(G ,src/server.ts:354-359)
let status = try await deleteLiveSession(server: server, id: idString, origin: server.origin)
// Assert: 204;id (killById ); close
// exit (close-first ,src/session/manager.ts:202-212)
#expect(status == 204, "kill 应 204,实际 \(status)")
let idsAfter = try await liveSessionIds(server: server)
#expect(!idsAfter.contains(idString), "kill 后 id 不应再出现在 GET /live-sessions")
let observedB = await drainUntilClose(client: clientB)
#expect(observedB.closed, "镜像客户端 B 应观察到 WS close")
#expect(!observedB.sawExit, "kill 路径不广播 exit 帧(先 close 后 kill)")
}
@Test("自然退出广播:A input exit → 镜像 B 收到 {type:'exit'} 帧")
func naturalExitBroadcastsExitFrameToMirror() async throws {
// Arrange
let server = try await ServerHarness.shared.server()
let clientA = WSTestClient(server: server, origin: server.origin)
let clientB = WSTestClient(server: server, origin: server.origin)
defer {
clientA.close()
clientB.close()
}
let sessionId = try await attachAndAwaitAttached(client: clientA, sessionId: nil)
_ = try await attachAndAwaitAttached(client: clientB, sessionId: sessionId)
// Act: A shell 退
try await clientA.send(shellCommand("exit"))
// Assert: B exit (pty.onExit 广,src/session/session.ts:146-151)
let exit = try await awaitExitFrame(client: clientB)
#expect(exit.code == 0, "bash `exit` 应干净退出,实际 code=\(exit.code)")
// Cleanup(退;kill )
clientA.close()
clientB.close()
await killSessionBestEffort(server: server, id: sessionId)
}
}

View File

@@ -0,0 +1,67 @@
//
// OriginGuardTests.swift T-iOS-16 5Origin spike
//
// plan §5.1 " CI Origin "= CRITICAL
// 401/403 ""
// - WS Origin 401src/server.ts:646-651 Origin
// - WS Origin 401src/http/origin.ts:47-51
// :443/:80 new URL()
// - DELETE /live-sessions/:id Origin 403G id
// src/server.ts:332-339 requireAllowedOrigin
import Foundation
import Testing
import WireProtocol
@Suite("T-iOS-16 Origin 守卫(真服务器)", .serialized, .timeLimit(.minutes(5)))
struct OriginGuardTests {
@Test("无 Origin 的 WS 升级被 401 拒绝(服务器默认拒空 Origin)")
func upgradeWithoutOriginIsRejectedWith401() async throws {
let server = try await ServerHarness.shared.server()
let client = WSTestClient(server: server, origin: nil)
defer { client.close() }
let failure = await client.handshakeFailure(timeout: HarnessTunables.frameTimeout)
#expect(failure != nil, "无 Origin 升级必须失败")
#expect(client.handshakeStatusCode == 401,
"应收 401(src/server.ts:646-651),实际 status=\(String(describing: client.handshakeStatusCode)) err=\(failure.map(describeError) ?? "nil")")
}
@Test("端口失配的 Origin → 401(端口精确比对;默认端口规范化不是失配案例)")
func portMismatchedOriginIsRejectedWith401() async throws {
let server = try await ServerHarness.shared.server()
let mismatchPort = server.port == HarnessTunables.mismatchedOriginPort
? HarnessTunables.mismatchedOriginPortFallback
: HarnessTunables.mismatchedOriginPort
let badOrigin = "http://127.0.0.1:\(mismatchPort)"
let client = WSTestClient(server: server, origin: badOrigin)
defer { client.close() }
let failure = await client.handshakeFailure(timeout: HarnessTunables.frameTimeout)
#expect(failure != nil, "端口失配 Origin 升级必须失败")
#expect(client.handshakeStatusCode == 401,
"应收 401(src/http/origin.ts:47-51),实际 status=\(String(describing: client.handshakeStatusCode)) err=\(failure.map(describeError) ?? "nil")")
}
@Test("DELETE /live-sessions/:id 无 Origin → 403;带 Origin 的同样请求 → 404(差分证明 403 来自守卫)")
func deleteWithoutOriginIsRejectedWith403() async throws {
// Arrange: id id ,
let server = try await ServerHarness.shared.server()
let bogusId = UUID().uuidString.lowercased()
// Act
let statusWithoutOrigin = try await deleteLiveSession(
server: server, id: bogusId, origin: nil)
let statusWithOrigin = try await deleteLiveSession(
server: server, id: bogusId, origin: server.origin)
// Assert: Origin (403); Origin id (404)
#expect(statusWithoutOrigin == 403,
"无 Origin 的 DELETE 应 403(src/server.ts:332-339),实际 \(statusWithoutOrigin)")
#expect(statusWithOrigin == 404,
"带 Origin 但 id 不存在应 404(src/server.ts:354-359),实际 \(statusWithOrigin)")
}
}

View File

@@ -1,591 +0,0 @@
//
// OriginSpikeTests.swift T-iOS-2 Day-1 spike
//
// Node URLSessionWebSocketTask make-or-break
// plan §7 T-iOS-2
// Origin WS 401 src/server.ts:646-651 Origin
// `Origin` header URLSessionWebSocketTask
// attach(null) attached MED
// ring-buffer src/session/session.ts attachWs buffer.snapshot()
// maximumMessageSize=1MiB >1MiB withKnownIssue
// 16 MiB ESC/C0 JSON \uXXXX
// Originhttp://127.0.0.1:9999 401src/http/origin.ts:47-51
// :443 new URL()
//
//
// A. `swift test --package-path ios/IntegrationTests`
// spawn `node_modules/.bin/tsx src/server.ts`repo
// #filePath WEBTERM_REPO_ROOT 127.0.0.1
// SHELL_PATH=/bin/bashUSE_TMUX=0 ALLOWED_ORIGINS
// deriveAllowedOrigins http://127.0.0.1:<port>src/config.ts:187-226
// atexit $TMPDIR/webterm-spike-server-<port>.log
// B. WEBTERM_SERVER_URL=http://127.0.0.1:<port> loopback
// origin
//
// ** import WireProtocol**T-iOS-3
// 16 MiB SpikeTunables WireProtocol.Tunables
import Darwin
import Foundation
import Testing
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
// WireProtocol.TunablesT-iOS-3
private enum SpikeTunables {
/// plan §3.2.1 `Tunables.maxWSMessageBytes` import
/// 6 × SCROLLBACK_BYTES(2 MiB) +
static let maxWSMessageBytes = 16 * 1024 * 1024
/// src/config.ts:41 DEFAULT_WS_PATH
static let wsPath = "/term"
/// > URLSessionWebSocketTask 1 MiB1_048_576
static let bulkOutputBytes = 1_500_000
/// "\u{1B}[0m"4 1.6 MB
/// JSON ×2.25 3.6 MB>1MiB<16MiB<2MiB ring
static let escSequenceRepeats = 400_000
static let escSequenceRawBytes = escSequenceRepeats * 4
/// spike 退 fallback
static let mismatchedOriginPort = 9999
static let mismatchedOriginPortFallback = 9998
static let serverReadyTimeout: Duration = .seconds(40)
static let serverReadyPollInterval: Duration = .milliseconds(200)
static let frameTimeout: Duration = .seconds(15)
static let outputAccumulationTimeout: Duration = .seconds(90)
}
private enum SpikeError: Error, CustomStringConvertible {
case setup(String)
case timeout(String)
var description: String {
switch self {
case .setup(let detail): return "setup: \(detail)"
case .timeout(let detail): return "timeout: \(detail)"
}
}
}
//
private struct SpikeServer: Sendable {
let baseURL: URL
let origin: String
let wsURL: URL
let port: Int
/// + /env
static func make(baseURL: URL) throws -> SpikeServer {
guard let scheme = baseURL.scheme, scheme == "http" || scheme == "https" else {
throw SpikeError.setup("server URL 必须是 http(s)got \(baseURL)")
}
guard let host = baseURL.host, !host.isEmpty else {
throw SpikeError.setup("server URL 缺 host: \(baseURL)")
}
guard var comps = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
throw SpikeError.setup("server URL 无法解析: \(baseURL)")
}
comps.scheme = scheme == "https" ? "wss" : "ws"
comps.path = SpikeTunables.wsPath
comps.query = nil
guard let wsURL = comps.url else {
throw SpikeError.setup("无法派生 ws URL: \(baseURL)")
}
// Origin spike
let origin = baseURL.port.map { "\(scheme)://\(host):\($0)" } ?? "\(scheme)://\(host)"
let port = baseURL.port ?? (scheme == "https" ? 443 : 80)
return SpikeServer(baseURL: baseURL, origin: origin, wsURL: wsURL, port: port)
}
}
// death-pipe watchdog + atexit tsx
//
// Swift Testing runner 退 atexit handler tsx
// death-pipespawn /bin/sh watchdog stdin
// 退/
// watchdog `read` EOF kill pidatexit
private enum ServerProcessRegistry {
private static let lock = NSLock()
nonisolated(unsafe) private static var process: Process?
/// deinit watchdog
nonisolated(unsafe) private static var deathPipe: Pipe?
nonisolated(unsafe) private static var installedAtexit = false
static func register(_ p: Process) {
lock.lock()
defer { lock.unlock() }
process = p
deathPipe = try? spawnDeathWatchdog(serverPid: p.processIdentifier)
guard !installedAtexit else { return }
installedAtexit = true
atexit { ServerProcessRegistry.killNow() }
}
static func killNow() {
lock.lock()
defer { lock.unlock() }
if let p = process, p.isRunning { p.terminate() }
process = nil
}
private static func spawnDeathWatchdog(serverPid: Int32) throws -> Pipe {
let pipe = Pipe()
let watchdog = Process()
watchdog.executableURL = URL(fileURLWithPath: "/bin/sh")
watchdog.arguments = ["-c", "read _; kill \(serverPid) 2>/dev/null"]
watchdog.standardInput = pipe
watchdog.standardOutput = FileHandle.nullDevice
watchdog.standardError = FileHandle.nullDevice
try watchdog.run()
return pipe
}
}
// harnessactor spawn suite
private actor ServerHarness {
static let shared = ServerHarness()
private var cached: SpikeServer?
func ensureServer() async throws -> SpikeServer {
if let cached { return cached }
let server: SpikeServer
if let external = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"] {
guard let url = URL(string: external) else {
throw SpikeError.setup("WEBTERM_SERVER_URL 不是合法 URL: \(external)")
}
server = try SpikeServer.make(baseURL: url)
try await awaitReady(server, process: nil, logURL: nil)
} else {
let spawned = try spawnLocalServer()
ServerProcessRegistry.register(spawned.process)
do {
try await awaitReady(spawned.server, process: spawned.process, logURL: spawned.logURL)
} catch {
ServerProcessRegistry.killNow()
throw error
}
server = spawned.server
}
cached = server
return server
}
private func spawnLocalServer() throws -> (server: SpikeServer, process: Process, logURL: URL) {
let repoRoot = try locateRepoRoot()
let tsx = repoRoot.appending(path: "node_modules/.bin/tsx")
guard FileManager.default.isExecutableFile(atPath: tsx.path) else {
throw SpikeError.setup("找不到 \(tsx.path) — 先在 repo 根目录跑 npm install")
}
let port = try findFreeLoopbackPort()
guard let baseURL = URL(string: "http://127.0.0.1:\(port)") else {
throw SpikeError.setup("无法构造 baseURL, port=\(port)")
}
let server = try SpikeServer.make(baseURL: baseURL)
let logURL = FileManager.default.temporaryDirectory
.appending(path: "webterm-spike-server-\(port).log")
FileManager.default.createFile(atPath: logURL.path, contents: nil)
let logHandle = try FileHandle(forWritingTo: logURL)
let process = Process()
process.executableURL = tsx
process.arguments = ["src/server.ts"]
process.currentDirectoryURL = repoRoot
var env = ProcessInfo.processInfo.environment
env["PORT"] = String(port)
env["BIND_HOST"] = "127.0.0.1" // 0.0.0.0
env["SHELL_PATH"] = "/bin/bash" // zsh
env["USE_TMUX"] = "0"
env["PATH"] = (env["PATH"] ?? "") + ":/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
process.environment = env
process.standardOutput = logHandle
process.standardError = logHandle
try process.run()
return (server, process, logURL)
}
private func awaitReady(_ server: SpikeServer, process: Process?, logURL: URL?) async throws {
let probeURL = server.baseURL.appending(path: "live-sessions")
let deadline = ContinuousClock.now + SpikeTunables.serverReadyTimeout
while ContinuousClock.now < deadline {
if let process, !process.isRunning {
throw SpikeError.setup(
"服务器进程提前退出exit \(process.terminationStatus))— 日志: \(logURL?.path ?? "n/a")")
}
if let (data, response) = try? await URLSession.shared.data(from: probeURL),
(response as? HTTPURLResponse)?.statusCode == 200,
(try? JSONSerialization.jsonObject(with: data)) is [Any] {
return
}
try await Task.sleep(for: SpikeTunables.serverReadyPollInterval)
}
throw SpikeError.timeout(
"服务器 \(server.baseURL)\(SpikeTunables.serverReadyTimeout) 内未就绪 — 日志: \(logURL?.path ?? "n/a")")
}
}
private func locateRepoRoot() throws -> URL {
if let override = ProcessInfo.processInfo.environment["WEBTERM_REPO_ROOT"] {
return URL(fileURLWithPath: override, isDirectory: true)
}
// #filePath = <repo>/ios/IntegrationTests/OriginSpikeTests.swift
return URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // IntegrationTests
.deletingLastPathComponent() // ios
.deletingLastPathComponent() // repo root
}
/// bind(port=0) loopback close
/// awaitReady
private func findFreeLoopbackPort() throws -> Int {
let fd = socket(AF_INET, SOCK_STREAM, 0)
guard fd >= 0 else { throw SpikeError.setup("socket() 失败: errno \(errno)") }
defer { close(fd) }
var addr = sockaddr_in()
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = 0
addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
let bound = withUnsafePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
guard bound == 0 else { throw SpikeError.setup("bind() 失败: errno \(errno)") }
var out = sockaddr_in()
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
let named = withUnsafeMutablePointer(to: &out) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(fd, $0, &len) }
}
guard named == 0 else { throw SpikeError.setup("getsockname() 失败: errno \(errno)") }
return Int(UInt16(bigEndian: out.sin_port))
}
// WS task
private final class SpikeWSClient: @unchecked Sendable {
let task: URLSessionWebSocketTask
/// - Parameters:
/// - origin: nil = Origin headerspike
/// - maxMessageBytes: nil = 1 MiBspike
init(server: SpikeServer, origin: String?, maxMessageBytes: Int?) {
var request = URLRequest(url: server.wsURL)
request.timeoutInterval = 15
if let origin {
// 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
}
func sendFrame(_ object: [String: Any]) async throws {
let data = try JSONSerialization.data(withJSONObject: object)
guard let text = String(data: data, encoding: .utf8) else {
throw SpikeError.setup("帧序列化非 UTF-8")
}
try await task.send(.string(text))
}
/// 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)
}
}
private 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 SpikeError.timeout("操作超过 \(timeout)")
}
guard let first = try await group.next() else {
throw SpikeError.timeout("task group 无结果")
}
group.cancelAll()
return first
}
}
// ""src/protocol.ts:45-86
private struct ServerFrame: Decodable {
let type: String
let data: String?
let sessionId: String?
let code: Int?
}
private func decodeFrame(_ text: String) -> ServerFrame? {
guard let data = text.data(using: .utf8) else { return nil }
// nil crash
return try? JSONDecoder().decode(ServerFrame.self, from: data)
}
//
/// attach sessionId null src/protocol.ts:132-134
private func attachFrame(sessionId: String?) -> [String: Any] {
["type": "attach", "sessionId": sessionId ?? NSNull()]
}
/// input Enter \r0x0D \nCLAUDE.md gotcha
private func inputFrame(command: String) -> [String: Any] {
["type": "input", "data": command + "\r"]
}
/// attach attached output/statusattachWs attached
/// snapshotsrc/session/session.ts:158-170 / src/server.ts:721-733
private func attachAndAwaitAttached(client: SpikeWSClient, sessionId: String?) async throws -> String {
try await client.sendFrame(attachFrame(sessionId: sessionId))
let deadline = ContinuousClock.now + SpikeTunables.frameTimeout
while ContinuousClock.now < deadline {
let text = try await client.receiveText(timeout: SpikeTunables.frameTimeout)
guard let frame = decodeFrame(text) else { continue }
if frame.type == "attached", let id = frame.sessionId { return id }
}
throw SpikeError.timeout("attach 后未收到 attached 帧")
}
/// output UTF-8 minBytes
private func accumulateOutput(client: SpikeWSClient, minBytes: Int) async throws -> Int {
var total = 0
let deadline = ContinuousClock.now + SpikeTunables.outputAccumulationTimeout
while ContinuousClock.now < deadline {
let text = try await client.receiveText(timeout: SpikeTunables.outputAccumulationTimeout)
guard let frame = decodeFrame(text), frame.type == "output" else { continue }
total += (frame.data ?? "").utf8.count
if total >= minBytes { return total }
}
throw SpikeError.timeout("输出累计 \(total)/\(minBytes) 字节后超时")
}
private struct ReplaySummary: Sendable {
let adoptedSessionId: String
let totalOutputBytes: Int
let containsSoftReset: Bool
}
/// reattach attached output minBytes
private func replayResult(
client: SpikeWSClient, sessionId: String, minBytes: Int
) async -> Result<ReplaySummary, any Error> {
do {
try await client.sendFrame(attachFrame(sessionId: sessionId))
var adopted: String?
var total = 0
var sawSoftReset = false
let deadline = ContinuousClock.now + SpikeTunables.outputAccumulationTimeout
while ContinuousClock.now < deadline {
let text = try await client.receiveText(timeout: SpikeTunables.frameTimeout)
guard let frame = decodeFrame(text) else { continue }
switch frame.type {
case "attached":
adopted = frame.sessionId
case "output":
let data = frame.data ?? ""
total += data.utf8.count
// soft-reset \x1b[0msrc/types.ts:167-170
if !sawSoftReset, data.contains("\u{1B}[0m") { sawSoftReset = true }
default:
break
}
if let adopted, total >= minBytes {
return .success(ReplaySummary(
adoptedSessionId: adopted, totalOutputBytes: total, containsSoftReset: sawSoftReset))
}
}
return .failure(SpikeError.timeout(
"回放不完整: attached=\(adopted ?? "") bytes=\(total)/\(minBytes)"))
} catch {
return .failure(error)
}
}
/// maximumMessageSize NSPOSIXErrorDomain 40Darwin errno 40 =
/// EMSGSIZE "Message too long"plan §1 "ENOBUFS(40)"Darwin ENOBUFS
/// 55
private 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
}
private 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)"
}
/// DELETE /live-sessions/:id G Origin403
/// src/server.ts:332-339宿 bash
///
private func killSession(server: SpikeServer, id: String) async {
var request = URLRequest(url: server.baseURL.appending(path: "live-sessions/\(id)"))
request.httpMethod = "DELETE"
request.setValue(server.origin, forHTTPHeaderField: "Origin")
_ = try? await URLSession.shared.data(for: request)
}
// Spike
@Suite("T-iOS-2 Day-1 spike:URLSessionWebSocketTask 对真服务器", .serialized, .timeLimit(.minutes(5)))
struct OriginSpikeTests {
@Test("spike①: 无 Origin 的 WS 升级被 401 拒绝(服务器默认拒空 Origin)")
func upgradeWithoutOriginIsRejectedWith401() async throws {
let server = try await ServerHarness.shared.ensureServer()
let client = SpikeWSClient(server: server, origin: nil, maxMessageBytes: nil)
defer { client.close() }
let failure = await client.handshakeFailure(timeout: SpikeTunables.frameTimeout)
#expect(failure != nil, "无 Origin 升级必须失败")
#expect(client.handshakeStatusCode == 401,
"应收 401(src/server.ts:646-651),实际 status=\(String(describing: client.handshakeStatusCode)) err=\(failure.map(describeError) ?? "nil")")
}
@Test("spike②: 自定义 Origin 精确匹配 → 升级成功,attach(null) 收到 attached")
func customOriginHeaderIsSentAndAttachSucceeds() async throws {
let server = try await ServerHarness.shared.ensureServer()
let client = SpikeWSClient(
server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes)
defer { client.close() }
let sessionId = try await attachAndAwaitAttached(client: client, sessionId: nil)
#expect(UUID(uuidString: sessionId) != nil, "attached.sessionId 应为 UUID,got \(sessionId)")
#expect(client.handshakeStatusCode == 101,
"升级应 101,实际 \(String(describing: client.handshakeStatusCode))")
client.close()
await killSession(server: server, id: sessionId)
}
@Test("spike③: >1MiB 单帧回放 — 默认 1MiB 上限复现失败(known issue);16MiB 成功")
func replayOver1MiBFailsAtDefaultLimitAndSucceedsAt16MiB() async throws {
let server = try await ServerHarness.shared.ensureServer()
// :attach(null) >1MiB , detach(PTY/ring )
let generator = SpikeWSClient(
server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes)
let sessionId = try await attachAndAwaitAttached(client: generator, sessionId: nil)
try await generator.sendFrame(
inputFrame(command: "perl -e 'print \"a\" x \(SpikeTunables.bulkOutputBytes)'"))
let produced = try await accumulateOutput(
client: generator, minBytes: SpikeTunables.bulkOutputBytes)
#expect(produced >= SpikeTunables.bulkOutputBytes)
generator.close()
// : maximumMessageSize(1 MiB)
let defaultClient = SpikeWSClient(server: server, origin: server.origin, maxMessageBytes: nil)
let defaultResult = await replayResult(
client: defaultClient, sessionId: sessionId, minBytes: SpikeTunables.bulkOutputBytes)
defaultClient.close()
if case .failure(let error) = defaultResult {
#expect(isMessageTooLongError(error),
"失败模式应为 NSPOSIXErrorDomain EMSGSIZE(40)/ENOBUFS(55),实际 \(describeError(error))")
}
withKnownIssue("默认 maximumMessageSize=1MiB 收不下 >1MiB 单帧 ring-buffer 回放 — 平台限制,修法即 Tunables.maxWSMessageBytes=16MiB(若此处'意外通过',说明平台行为变了,须重新评估)") {
_ = try defaultResult.get()
}
// :16 MiB ,attached sessionId
let bigClient = SpikeWSClient(
server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes)
let summary = try await replayResult(
client: bigClient, sessionId: sessionId, minBytes: SpikeTunables.bulkOutputBytes).get()
#expect(summary.adoptedSessionId == sessionId)
#expect(summary.totalOutputBytes >= SpikeTunables.bulkOutputBytes)
bigClient.close()
await killSession(server: server, id: sessionId)
}
@Test("spike③-对抗: ESC/C0 密集输出(JSON 转义膨胀~6×)回放,16MiB 仍成功")
func escDenseReplayStillSucceedsAt16MiB() async throws {
let server = try await ServerHarness.shared.ensureServer()
let generator = SpikeWSClient(
server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes)
let sessionId = try await attachAndAwaitAttached(client: generator, sessionId: nil)
// perl \e = ESC(0x1B): 4 JSON 9 ("")
try await generator.sendFrame(
inputFrame(command: "perl -e 'print \"\\e[0m\" x \(SpikeTunables.escSequenceRepeats)'"))
let produced = try await accumulateOutput(
client: generator, minBytes: SpikeTunables.escSequenceRawBytes)
#expect(produced >= SpikeTunables.escSequenceRawBytes)
generator.close()
let bigClient = SpikeWSClient(
server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes)
let summary = try await replayResult(
client: bigClient, sessionId: sessionId, minBytes: SpikeTunables.escSequenceRawBytes).get()
#expect(summary.adoptedSessionId == sessionId)
#expect(summary.totalOutputBytes >= SpikeTunables.escSequenceRawBytes)
#expect(summary.containsSoftReset, "回放应含 ESC[0m(转义序列在回放中原样保留)")
bigClient.close()
await killSession(server: server, id: sessionId)
}
@Test("spike④: 端口失配的 Origin → 401(端口精确比对;默认端口规范化不是失配案例)")
func portMismatchedOriginIsRejectedWith401() async throws {
let server = try await ServerHarness.shared.ensureServer()
let mismatchPort = server.port == SpikeTunables.mismatchedOriginPort
? SpikeTunables.mismatchedOriginPortFallback
: SpikeTunables.mismatchedOriginPort
// ::443/:80 scheme new URL()
// (src/http/origin.ts:31-51),;
let badOrigin = "http://127.0.0.1:\(mismatchPort)"
let client = SpikeWSClient(server: server, origin: badOrigin, maxMessageBytes: nil)
defer { client.close() }
let failure = await client.handshakeFailure(timeout: SpikeTunables.frameTimeout)
#expect(failure != nil, "端口失配 Origin 升级必须失败")
#expect(client.handshakeStatusCode == 401,
"应收 401(src/http/origin.ts:47-51),实际 status=\(String(describing: client.handshakeStatusCode)) err=\(failure.map(describeError) ?? "nil")")
}
}

View File

@@ -1,7 +1,9 @@
// swift-tools-version: 6.0
// T-iOS-1 scaffold shell. Real live-server tests land in T-iOS-2
// (OriginSpikeTests, against `npm start`) and T-iOS-16 (CI integration).
// T-iOS-16 integration-CI package: Swift Testing suites against the REAL Node
// server (self-bootstrapped by ServerHarness spawns `tsx src/server.ts` on an
// ephemeral loopback port; see ServerHarness.swift for the run modes).
// Flat layout (plan §2): test sources live directly in ios/IntegrationTests/.
// `scripts/` holds the per-package coverage gate used by .github/workflows/ios.yml.
import PackageDescription
let package = Package(
@@ -14,7 +16,8 @@ let package = Package(
.testTarget(
name: "IntegrationTests",
dependencies: [.product(name: "WireProtocol", package: "WireProtocol")],
path: "."
path: ".",
exclude: ["scripts"]
),
],
swiftLanguageModes: [.v6]

View File

@@ -0,0 +1,74 @@
//
// ProtocolFlowTests.swift T-iOS-16 1-2 spike
//
// 1. attach(null) attached input "echo hi\r" output "hi"
// RUN:42 shell
// 2. resize(120,40) `stty size` "40 120" resize 1/1000
// stty "" resize
// src/protocol.ts:113-115
//
// MessageCodec MessageCodec suite
// plan §9
import Foundation
import Testing
import WireProtocol
@Suite("T-iOS-16 协议流程(真服务器)", .serialized, .timeLimit(.minutes(5)))
struct ProtocolFlowTests {
@Test("attach(null)→attached(101);echo hi → output 含 hi 且命令确被执行")
func attachEchoRoundtrip() async throws {
// Arrange
let server = try await ServerHarness.shared.server()
let client = WSTestClient(server: server, origin: server.origin)
defer { client.close() }
// Act: attach(null) attached(spike : Origin 101)
let sessionId = try await attachAndAwaitAttached(client: client, sessionId: nil)
#expect(client.handshakeStatusCode == 101,
"升级应 101,实际 \(String(describing: client.handshakeStatusCode))")
var reader = TranscriptReader(client: client)
try await client.send(shellCommand("echo hi"))
try await reader.awaitContains("hi")
// :$((6*7)) shell 42()
try await client.send(shellCommand("echo \"RUN:$((6*7))\""))
try await reader.awaitContains("RUN:42")
// Cleanup
client.close()
await killSessionBestEffort(server: server, id: sessionId)
}
@Test("resize(120,40) → stty size = 40 120;边界 1/1000 均被采纳且连接存活")
func resizeTakesEffectIncludingBoundaries() async throws {
// Arrange
let server = try await ServerHarness.shared.server()
let client = WSTestClient(server: server, origin: server.origin)
defer { client.close() }
let sessionId = try await attachAndAwaitAttached(client: client, sessionId: nil)
var reader = TranscriptReader(client: client)
// Act + Assert: ()SZ:
// $(stty size), "SZ:<rows> <cols>"
try await client.send(.resize(cols: 120, rows: 40))
try await client.send(shellCommand("echo \"SZ:$(stty size)\""))
try await reader.awaitContains("SZ:40 120")
// :1×1 (stty 1 1),
try await client.send(.resize(cols: 1, rows: 1))
try await client.send(shellCommand("echo \"SZ:$(stty size)\""))
try await reader.awaitContains("SZ:1 1")
// :1000×1000 ,
try await client.send(.resize(cols: 1000, rows: 1000))
try await client.send(shellCommand("echo \"SZ:$(stty size)\""))
try await reader.awaitContains("SZ:1000 1000")
// Cleanup
client.close()
await killSessionBestEffort(server: server, id: sessionId)
}
}

View File

@@ -0,0 +1,79 @@
//
// ReplayTests.swift T-iOS-16 3>1 MiB spike +
//
// ring-buffer src/session/session.ts attachWs buffer.snapshot()
// JSON \uXXXX 1-6×src/protocol.ts:186
// - maximumMessageSize=1 MiB withKnownIssue
// "" Tunables.maxWSMessageBytes
// - Tunables.maxWSMessageBytes16 MiB
// - ESC/C0 ~6×
import Foundation
import Testing
import WireProtocol
@Suite("T-iOS-16 回放上限(真服务器)", .serialized, .timeLimit(.minutes(5)))
struct ReplayTests {
@Test(">1MiB 单帧回放 — 默认 1MiB 上限复现失败(known issue);Tunables 16MiB 成功")
func replayOver1MiBFailsAtDefaultLimitAndSucceedsAt16MiB() async throws {
let server = try await ServerHarness.shared.server()
// :attach(null) >1MiB , detach(PTY/ring )
let generator = WSTestClient(server: server, origin: server.origin)
let sessionId = try await attachAndAwaitAttached(client: generator, sessionId: nil)
try await generator.send(shellCommand(
"perl -e 'print \"a\" x \(HarnessTunables.bulkOutputBytes)'"))
let produced = try await accumulateOutputBytes(
client: generator, minBytes: HarnessTunables.bulkOutputBytes)
#expect(produced >= HarnessTunables.bulkOutputBytes)
generator.close()
// : maximumMessageSize(1 MiB)
let defaultClient = WSTestClient(server: server, origin: server.origin, maxMessageBytes: nil)
let defaultResult = await replayResult(
client: defaultClient, sessionId: sessionId, minBytes: HarnessTunables.bulkOutputBytes)
defaultClient.close()
if case .failure(let error) = defaultResult {
#expect(isMessageTooLongError(error),
"失败模式应为 NSPOSIXErrorDomain EMSGSIZE(40)/ENOBUFS(55),实际 \(describeError(error))")
}
withKnownIssue("默认 maximumMessageSize=1MiB 收不下 >1MiB 单帧 ring-buffer 回放 — 平台限制,修法即 Tunables.maxWSMessageBytes=16MiB(若此处'意外通过',说明平台行为变了,须重新评估)") {
_ = try defaultResult.get()
}
// :16 MiB ,attached sessionId
let bigClient = WSTestClient(server: server, origin: server.origin)
let summary = try await replayResult(
client: bigClient, sessionId: sessionId, minBytes: HarnessTunables.bulkOutputBytes).get()
#expect(summary.adoptedSessionId == sessionId)
#expect(summary.totalOutputBytes >= HarnessTunables.bulkOutputBytes)
bigClient.close()
await killSessionBestEffort(server: server, id: sessionId)
}
@Test("对抗: ESC/C0 密集输出(JSON 转义膨胀~6×)回放,16MiB 仍完整")
func escDenseReplayStillSucceedsAt16MiB() async throws {
let server = try await ServerHarness.shared.server()
let generator = WSTestClient(server: server, origin: server.origin)
let sessionId = try await attachAndAwaitAttached(client: generator, sessionId: nil)
// perl \e = ESC(0x1B): 4 JSON 9 ("[0m")
try await generator.send(shellCommand(
"perl -e 'print \"\\e[0m\" x \(HarnessTunables.escSequenceRepeats)'"))
let produced = try await accumulateOutputBytes(
client: generator, minBytes: HarnessTunables.escSequenceRawBytes)
#expect(produced >= HarnessTunables.escSequenceRawBytes)
generator.close()
let bigClient = WSTestClient(server: server, origin: server.origin)
let summary = try await replayResult(
client: bigClient, sessionId: sessionId,
minBytes: HarnessTunables.escSequenceRawBytes).get()
#expect(summary.adoptedSessionId == sessionId)
#expect(summary.totalOutputBytes >= HarnessTunables.escSequenceRawBytes)
#expect(summary.containsSoftReset, "回放应含 ESC[0m(转义序列在回放中原样保留)")
bigClient.close()
await killSessionBestEffort(server: server, id: sessionId)
}
}

View File

@@ -0,0 +1,260 @@
//
// ServerHarness.swift T-iOS-16 Node
//
// T-iOS-2 OriginSpikeTests.swift591 plan §7 T-iOS-16
// harness + suites
// - spawn `node_modules/.bin/tsx src/server.ts`
// suite
// - GET /live-sessions JSON
// - death-pipe watchdog + atexit Swift Testing runner 退
// atexitwatchdog
//
//
// A. `swift test --package-path ios/IntegrationTests`
// repo #filePath WEBTERM_REPO_ROOT 127.0.0.1
// SHELL_PATH=/bin/bashUSE_TMUX=0
// ALLOWED_ORIGINS deriveAllowedOrigins
// http://127.0.0.1:<port>src/config.ts:187-226
// $TMPDIR/webterm-integration-server-<port>.log
// B. WEBTERM_SERVER_URL=http://127.0.0.1:<port> loopback
// origin
//
// spike WireProtocol T-iOS-3Origin/wsURL
// `HostEndpoint` plan §5.1
import Darwin
import Foundation
import WireProtocol
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
// 线 WireProtocol.Tunables/WireConstants
enum HarnessTunables {
/// > URLSessionWebSocketTask 1 MiB1_048_576
static let bulkOutputBytes = 1_500_000
/// "\u{1B}[0m"4 1.6 MB
/// JSON ×2.25 3.6 MB>1MiB<16MiB<2MiB ring
static let escSequenceRepeats = 400_000
static let escSequenceRawBytes = escSequenceRepeats * 4
/// Origin 退 fallback
static let mismatchedOriginPort = 9999
static let mismatchedOriginPortFallback = 9998
static let serverReadyTimeout: Duration = .seconds(40)
static let serverReadyPollInterval: Duration = .milliseconds(200)
/// attachattached
static let frameTimeout: Duration = .seconds(15)
/// shell bash
static let markerTimeout: Duration = .seconds(30)
/// >1MiB /
static let outputAccumulationTimeout: Duration = .seconds(90)
/// WS kill
static let closeObserveTimeout: Duration = .seconds(30)
}
enum HarnessError: Error, CustomStringConvertible {
case setup(String)
case timeout(String)
var description: String {
switch self {
case .setup(let detail): return "setup: \(detail)"
case .timeout(let detail): return "timeout: \(detail)"
}
}
}
// HostEndpoint
struct TestServer: Sendable {
let endpoint: HostEndpoint
var baseURL: URL { endpoint.baseURL }
var wsURL: URL { endpoint.wsURL }
/// Origin HostEndpoint plan §5.1
var origin: String { endpoint.originHeader }
var port: Int { endpoint.baseURL.port ?? (endpoint.baseURL.scheme == "https" ? 443 : 80) }
/// + /env
static func make(baseURL: URL) throws -> TestServer {
guard let endpoint = HostEndpoint(baseURL: baseURL) else {
throw HarnessError.setup("server URL 必须是带 host 的 http(s)got \(baseURL)")
}
return TestServer(endpoint: endpoint)
}
}
// death-pipe watchdog + atexit tsx
//
// T-iOS-2Swift Testing runner 退 atexit handler
// tsx death-pipespawn /bin/sh watchdog stdin
// 退/
// watchdog `read` EOF kill pidatexit
private enum ServerProcessRegistry {
private static let lock = NSLock()
nonisolated(unsafe) private static var process: Process?
/// deinit watchdog
nonisolated(unsafe) private static var deathPipe: Pipe?
nonisolated(unsafe) private static var installedAtexit = false
static func register(_ p: Process) {
lock.lock()
defer { lock.unlock() }
process = p
deathPipe = try? spawnDeathWatchdog(serverPid: p.processIdentifier)
guard !installedAtexit else { return }
installedAtexit = true
atexit { ServerProcessRegistry.killNow() }
}
static func killNow() {
lock.lock()
defer { lock.unlock() }
if let p = process, p.isRunning { p.terminate() }
process = nil
}
private static func spawnDeathWatchdog(serverPid: Int32) throws -> Pipe {
let pipe = Pipe()
let watchdog = Process()
watchdog.executableURL = URL(fileURLWithPath: "/bin/sh")
watchdog.arguments = ["-c", "read _; kill \(serverPid) 2>/dev/null"]
watchdog.standardInput = pipe
watchdog.standardOutput = FileHandle.nullDevice
watchdog.standardError = FileHandle.nullDevice
try watchdog.run()
return pipe
}
}
// harnessactor spawn suite
//
// Swift Testing suite suite server()actor
// awaitReady ""
// spawn in-flight Task await
actor ServerHarness {
static let shared = ServerHarness()
private var bootTask: Task<TestServer, any Error>?
func server() async throws -> TestServer {
if let bootTask { return try await bootTask.value }
let task = Task { try await Self.bootstrap() }
bootTask = task
return try await task.value
}
private static func bootstrap() async throws -> TestServer {
if let external = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"] {
guard let url = URL(string: external) else {
throw HarnessError.setup("WEBTERM_SERVER_URL 不是合法 URL: \(external)")
}
let server = try TestServer.make(baseURL: url)
try await awaitReady(server, process: nil, logURL: nil)
return server
}
let spawned = try spawnLocalServer()
ServerProcessRegistry.register(spawned.process)
do {
try await awaitReady(spawned.server, process: spawned.process, logURL: spawned.logURL)
} catch {
ServerProcessRegistry.killNow()
throw error
}
return spawned.server
}
private static func spawnLocalServer() throws -> (server: TestServer, process: Process, logURL: URL) {
let repoRoot = try locateRepoRoot()
let tsx = repoRoot.appending(path: "node_modules/.bin/tsx")
guard FileManager.default.isExecutableFile(atPath: tsx.path) else {
throw HarnessError.setup("找不到 \(tsx.path) — 先在 repo 根目录跑 npm install/npm ci")
}
let port = try findFreeLoopbackPort()
guard let baseURL = URL(string: "http://127.0.0.1:\(port)") else {
throw HarnessError.setup("无法构造 baseURL, port=\(port)")
}
let server = try TestServer.make(baseURL: baseURL)
let logURL = FileManager.default.temporaryDirectory
.appending(path: "webterm-integration-server-\(port).log")
FileManager.default.createFile(atPath: logURL.path, contents: nil)
let logHandle = try FileHandle(forWritingTo: logURL)
let process = Process()
process.executableURL = tsx
process.arguments = ["src/server.ts"]
process.currentDirectoryURL = repoRoot
var env = ProcessInfo.processInfo.environment
env["PORT"] = String(port)
env["BIND_HOST"] = "127.0.0.1" // 0.0.0.0
env["SHELL_PATH"] = "/bin/bash" // zsh
env["USE_TMUX"] = "0"
env["PATH"] = (env["PATH"] ?? "") + ":/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
process.environment = env
process.standardOutput = logHandle
process.standardError = logHandle
try process.run()
return (server, process, logURL)
}
private static func awaitReady(_ server: TestServer, process: Process?, logURL: URL?) async throws {
let probeURL = server.baseURL.appending(path: "live-sessions")
let deadline = ContinuousClock.now + HarnessTunables.serverReadyTimeout
while ContinuousClock.now < deadline {
if let process, !process.isRunning {
throw HarnessError.setup(
"服务器进程提前退出exit \(process.terminationStatus))— 日志: \(logURL?.path ?? "n/a")")
}
if let (data, response) = try? await URLSession.shared.data(from: probeURL),
(response as? HTTPURLResponse)?.statusCode == 200,
(try? JSONSerialization.jsonObject(with: data)) is [Any] {
return
}
try await Task.sleep(for: HarnessTunables.serverReadyPollInterval)
}
throw HarnessError.timeout(
"服务器 \(server.baseURL)\(HarnessTunables.serverReadyTimeout) 内未就绪 — 日志: \(logURL?.path ?? "n/a")")
}
}
func locateRepoRoot() throws -> URL {
if let override = ProcessInfo.processInfo.environment["WEBTERM_REPO_ROOT"] {
return URL(fileURLWithPath: override, isDirectory: true)
}
// #filePath = <repo>/ios/IntegrationTests/ServerHarness.swift
return URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // IntegrationTests
.deletingLastPathComponent() // ios
.deletingLastPathComponent() // repo root
}
/// bind(port=0) loopback close
/// awaitReady
func findFreeLoopbackPort() throws -> Int {
let fd = socket(AF_INET, SOCK_STREAM, 0)
guard fd >= 0 else { throw HarnessError.setup("socket() 失败: errno \(errno)") }
defer { close(fd) }
var addr = sockaddr_in()
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = 0
addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
let bound = withUnsafePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
guard bound == 0 else { throw HarnessError.setup("bind() 失败: errno \(errno)") }
var out = sockaddr_in()
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
let named = withUnsafeMutablePointer(to: &out) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(fd, $0, &len) }
}
guard named == 0 else { throw HarnessError.setup("getsockname() 失败: errno \(errno)") }
return Int(UInt16(bigEndian: out.sin_port))
}

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

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
#
# coverage-gate.sh — T-iOS-16 per-package OWN-SOURCES coverage gate (>= 80%).
#
# Usage: coverage-gate.sh <PackageName> # e.g. coverage-gate.sh WireProtocol
# Env: COVERAGE_THRESHOLD=<percent> # default 80 (red/green demo knob)
#
# WHY THIS EXISTS (plan §9 flaw, fix assigned to T-iOS-16): the raw §9 command
# xcrun llvm-cov export -summary-only ... -ignore-filename-regex '(Tests|TestSupport|\.build)/'
# | jq '.data[0].totals.lines.percent >= 80'
# reads the export TOTALS, which include every STATICALLY-LINKED DEPENDENCY
# source compiled into the test binary (e.g. WireProtocol sources inside
# SessionCore's test run). That lets a package's own weak coverage hide behind a
# well-covered dependency (or vice versa). The corrected filter below keeps ONLY
# files under Packages/<P>/Sources/ (excluding *Placeholder* scaffolding) and
# recomputes line coverage from the per-file summaries.
#
# Exit codes: 0 = gate passed; 1 = below threshold, filter matched no sources,
# or any build/test failure (set -e).
set -euo pipefail
PKG="${1:?usage: coverage-gate.sh <PackageName> (WireProtocol|SessionCore|HostRegistry|APIClient)}"
THRESHOLD="${COVERAGE_THRESHOLD:-80}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
PKG_DIR="$REPO_ROOT/ios/Packages/$PKG"
if [ ! -d "$PKG_DIR" ]; then
echo "coverage-gate($PKG): package dir not found: $PKG_DIR" >&2
exit 1
fi
# 1) Run the package tests with coverage instrumentation (test failure => gate red).
swift test --package-path "$PKG_DIR" --enable-code-coverage
# 2) Locate the test binary and the merged profile (paths per plan §9).
BIN="$(swift build --package-path "$PKG_DIR" --show-bin-path)/${PKG}PackageTests.xctest/Contents/MacOS/${PKG}PackageTests"
PROF="$(dirname "$(swift test --package-path "$PKG_DIR" --show-codecov-path)")/default.profdata"
# 3) Export per-file summaries and keep ONLY this package's own production
# sources: path must contain /Packages/<PKG>/Sources/ and must not be a
# *Placeholder* scaffold file. Dependency sources (other packages), Tests/,
# TestSupport and .build shims all fail the path filter automatically.
read -r COVERED TOTAL < <(
xcrun llvm-cov export -summary-only "$BIN" -instr-profile "$PROF" \
| jq -r --arg pkg "$PKG" '
[.data[0].files[]
| select(.filename | contains("/Packages/" + $pkg + "/Sources/"))
| select(.filename | contains("Placeholder") | not)
| .summary.lines]
| "\(map(.covered) | add // 0) \(map(.count) | add // 0)"'
)
if [ "$TOTAL" -eq 0 ]; then
echo "coverage-gate($PKG): FAIL — filter matched no own-source files under" \
"Packages/$PKG/Sources/ (filter bug or empty package?)" >&2
exit 1
fi
# LC_ALL=C on both awk calls: a comma-decimal locale would print "100,00",
# which awk then compares as a STRING against the threshold (silent false FAIL).
PCT="$(LC_ALL=C awk -v c="$COVERED" -v t="$TOTAL" 'BEGIN { printf "%.2f", c * 100 / t }')"
echo "coverage-gate($PKG): own-sources line coverage ${PCT}% (${COVERED}/${TOTAL} lines, threshold ${THRESHOLD}%)"
if ! LC_ALL=C awk -v p="$PCT" -v th="$THRESHOLD" 'BEGIN { exit !(p >= th) }'; then
echo "coverage-gate($PKG): FAIL — below threshold ${THRESHOLD}%" >&2
exit 1
fi
echo "coverage-gate($PKG): PASS"