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