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:
@@ -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")
|
||||
}
|
||||
100
ios/IntegrationTests/MirrorLifecycleTests.swift
Normal file
100
ios/IntegrationTests/MirrorLifecycleTests.swift
Normal file
@@ -0,0 +1,100 @@
|
||||
//
|
||||
// MirrorLifecycleTests.swift — T-iOS-16 测试 4/6/7:JOIN mirror 与会话生命周期
|
||||
//
|
||||
// 4. 双客户端 JOIN mirror:A、B 同 attach 一个会话(新 attach 加入镜像、绝不
|
||||
// 踢人,src/session/manager.ts:108-174),A input,B 收到 output。
|
||||
// 6. DELETE /live-sessions/:id(kill)→ 镜像客户端观察到 WS close —— 不是
|
||||
// exit 帧:manager.killById 先 ws.close() 全部客户端再 kill PTY
|
||||
// (src/session/manager.ts:202-212),exit 广播只投给 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)
|
||||
}
|
||||
}
|
||||
67
ios/IntegrationTests/OriginGuardTests.swift
Normal file
67
ios/IntegrationTests/OriginGuardTests.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// OriginGuardTests.swift — T-iOS-16 测试 5:Origin 守卫回归(吸收 spike①④)
|
||||
//
|
||||
// plan §5.1 的自动化化身。安全注:任何"为了过 CI 放宽 Origin 断言"= CRITICAL——
|
||||
// 401/403 的精确断言不许软化成"连接失败即可"。
|
||||
// - WS 升级无 Origin → 401(src/server.ts:646-651,默认拒空 Origin)
|
||||
// - WS 升级端口失配 Origin → 401(src/http/origin.ts:47-51 端口精确比对;
|
||||
// 注意 :443/:80 这类默认端口被两侧 new URL() 对称规范化,不是失配案例)
|
||||
// - DELETE /live-sessions/:id 无 Origin → 403(G 守卫先于 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)")
|
||||
}
|
||||
}
|
||||
@@ -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 转义膨胀)仍成功
|
||||
// ④ 端口失配的 Origin(http://127.0.0.1:9999)→ 401(src/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/bash、USE_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.Tunables,T-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 MiB(1_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-pipe:spawn 一个 /bin/sh watchdog,其 stdin 是本测试
|
||||
// 进程持有的管道写端 —— 测试进程无论以何种方式退出(正常/崩溃),写端关闭 →
|
||||
// watchdog 的 `read` 收到 EOF → kill 服务器 pid。atexit 仅作快速路径兜底。
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 服务器 harness(actor 单例: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 header(spike ①)。
|
||||
/// - maxMessageBytes: nil = 保持平台默认 1 MiB(spike ③ 复现分支)。
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 升级失败探测:能收到帧/正常等待 → 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)
|
||||
}
|
||||
}
|
||||
|
||||
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 是 \r(0x0D),不是 \n(CLAUDE.md gotcha)。
|
||||
private func inputFrame(command: String) -> [String: Any] {
|
||||
["type": "input", "data": command + "\r"]
|
||||
}
|
||||
|
||||
/// attach 后等 attached 帧;容忍先到的 output/status(attachWs 在 attached
|
||||
/// 之前就回放 snapshot,src/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[0m(src/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 40(Darwin 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 类端点,必须带 Origin(403 否则,
|
||||
/// 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 字节("[0m")。
|
||||
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")")
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
74
ios/IntegrationTests/ProtocolFlowTests.swift
Normal file
74
ios/IntegrationTests/ProtocolFlowTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
79
ios/IntegrationTests/ReplayTests.swift
Normal file
79
ios/IntegrationTests/ReplayTests.swift
Normal 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.maxWSMessageBytes(16 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)
|
||||
}
|
||||
}
|
||||
260
ios/IntegrationTests/ServerHarness.swift
Normal file
260
ios/IntegrationTests/ServerHarness.swift
Normal file
@@ -0,0 +1,260 @@
|
||||
//
|
||||
// ServerHarness.swift — T-iOS-16 集成测试基础设施:真 Node 服务器自举
|
||||
//
|
||||
// 由 T-iOS-2 的 OriginSpikeTests.swift(591 行单文件)拆分而来(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 退出
|
||||
// 时不可靠地跑 atexit,watchdog 是主机制)。
|
||||
//
|
||||
// 运行方式(可复现):
|
||||
// A. 默认自举:`swift test --package-path ios/IntegrationTests`
|
||||
// repo 根由 #filePath 推导(可用 WEBTERM_REPO_ROOT 覆盖),绑定 127.0.0.1
|
||||
// 的空闲端口,SHELL_PATH=/bin/bash(确定性输出)、USE_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-3),Origin/wsURL 一律由
|
||||
// `HostEndpoint` 单点派生(plan §5.1 铁律:禁止手拼),不再本地复刻常量。
|
||||
|
||||
import Darwin
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
// ─── 常量(无魔法数字;线协议常量一律取 WireProtocol.Tunables/WireConstants) ──
|
||||
|
||||
enum HarnessTunables {
|
||||
/// 灌入的普通输出字节数:> URLSessionWebSocketTask 默认 1 MiB(1_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)
|
||||
/// 单帧等待上限(attach→attached 等短往返)。
|
||||
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-2):Swift Testing runner 退出时不跑 atexit handler(首轮运行留下
|
||||
// 孤儿 tsx),所以主回收机制是 death-pipe:spawn 一个 /bin/sh watchdog,其 stdin
|
||||
// 是本测试进程持有的管道写端 —— 测试进程无论以何种方式退出(正常/崩溃),写端
|
||||
// 关闭 → watchdog 的 `read` 收到 EOF → kill 服务器 pid。atexit 仅作快速路径兜底。
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 服务器 harness(actor 单例: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))
|
||||
}
|
||||
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)
|
||||
}
|
||||
72
ios/IntegrationTests/scripts/coverage-gate.sh
Executable file
72
ios/IntegrationTests/scripts/coverage-gate.sh
Executable 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"
|
||||
Reference in New Issue
Block a user