feat(ios): W0 scaffold + day-1 spike + WireProtocol frozen contract + TestSupport doubles
T-iOS-1: ios/ XcodeGen project (iOS 17, Swift 6 strict concurrency, ATS per PLAN §5.2), 5 SPM package shells, CI skeleton T-iOS-2: Origin spike vs real server — URLSessionWebSocketTask custom Origin CONFIRMED (no Starscream); 16MiB replay + EMSGSIZE(40) errno correction written back to plan T-iOS-3: WireProtocol frozen contract, 59 tests, 100% line coverage, cross-impl vectors vs src/protocol.ts via tsx T-iOS-4: FakeTransport/FakeClock/FakeHTTPTransport doubles Verify: independent agent re-ran all acceptance — 6/6 PASS
This commit is contained in:
13
ios/IntegrationTests/LiveServerTests.swift
Normal file
13
ios/IntegrationTests/LiveServerTests.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
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")
|
||||
}
|
||||
591
ios/IntegrationTests/OriginSpikeTests.swift
Normal file
591
ios/IntegrationTests/OriginSpikeTests.swift
Normal file
@@ -0,0 +1,591 @@
|
||||
//
|
||||
// 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")")
|
||||
}
|
||||
}
|
||||
21
ios/IntegrationTests/Package.swift
Normal file
21
ios/IntegrationTests/Package.swift
Normal file
@@ -0,0 +1,21 @@
|
||||
// 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).
|
||||
// Flat layout (plan §2): test sources live directly in ios/IntegrationTests/.
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "IntegrationTests",
|
||||
platforms: [.iOS(.v17), .macOS(.v14)],
|
||||
dependencies: [
|
||||
.package(path: "../Packages/WireProtocol"),
|
||||
],
|
||||
targets: [
|
||||
.testTarget(
|
||||
name: "IntegrationTests",
|
||||
dependencies: [.product(name: "WireProtocol", package: "WireProtocol")],
|
||||
path: "."
|
||||
),
|
||||
],
|
||||
swiftLanguageModes: [.v6]
|
||||
)
|
||||
Reference in New Issue
Block a user