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:
10
ios/.gitignore
vendored
Normal file
10
ios/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Generated by xcodegen — regenerate with: cd ios && xcodegen generate
|
||||
*.xcodeproj
|
||||
# XcodeGen-generated Info.plist (keys are declared in project.yml)
|
||||
App/WebTerm/Resources/Info.plist
|
||||
|
||||
# Xcode / SwiftPM build state
|
||||
DerivedData/
|
||||
xcuserdata/
|
||||
.build/
|
||||
.swiftpm/
|
||||
89
ios/App/WebTerm/Screens/SpikeTerminalScreen.swift
Normal file
89
ios/App/WebTerm/Screens/SpikeTerminalScreen.swift
Normal file
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// SpikeTerminalScreen.swift — T-iOS-2 Day-1 spike ②【临时文件,W4/T-iOS-15 删除】
|
||||
//
|
||||
// 目的:SwiftTerm TerminalView 的真机键盘/IME smoke 载体(plan §7 T-iOS-2):
|
||||
// - 键盘弹出 / first-responder 行为
|
||||
// - 中文 IME 组合输入(不自加 keydown 监听,组合事件全交系统栈)
|
||||
// - inputAccessoryView 上 Esc/Ctrl-C 可发 —— SwiftTerm iOS 端自带
|
||||
// TerminalAccessory(含 esc/ctrl/tab 键,iOSAccessoryView.swift),
|
||||
// spike 直接用它验证;正式 key-bar(逐字节复刻 public/keybar.ts)归 T-iOS-11
|
||||
// - 文本选择不崩(SwiftTerm 自绘选择是历史坑点)
|
||||
//
|
||||
// 接线为【纯本地回显】(无服务器依赖):typed bytes → send() → feed() 原样喂回,
|
||||
// CR 扩成 CRLF。真实 SessionEngine 接线是 W2/W3 的事。
|
||||
//
|
||||
// 2026-07-04:无可用真机 —— 真机清单各项记 DEFERRED(真机不可用)(见 PROGRESS_LOG
|
||||
// 条目)。本文件当下的验收是:作为 App target 源码在模拟器 xcodebuild 构建通过。
|
||||
|
||||
import SwiftTerm
|
||||
import SwiftUI
|
||||
|
||||
/// Day-1 spike 的最小 SwiftUI 壳。不接入 WebTermApp 导航(WebTermApp.swift 归
|
||||
/// T-iOS-1/T-iOS-15 所有);存在本身即证明 App target 在 Swift 6 strict
|
||||
/// concurrency 下能链接并承载 SwiftTerm 的 TerminalView。
|
||||
struct SpikeTerminalScreen: View {
|
||||
var body: some View {
|
||||
LocalEchoTerminal()
|
||||
.ignoresSafeArea(.container, edges: .bottom)
|
||||
.navigationTitle("SwiftTerm spike")
|
||||
}
|
||||
}
|
||||
|
||||
/// 承载 SwiftTerm UIKit `TerminalView` 的 UIViewRepresentable,接本地回显。
|
||||
/// 形状即 T-iOS-11 正式 TerminalScreen 的雏形(去掉 SessionEngine)。
|
||||
private struct LocalEchoTerminal: UIViewRepresentable {
|
||||
func makeCoordinator() -> LocalEchoCoordinator {
|
||||
LocalEchoCoordinator()
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> TerminalView {
|
||||
let view = TerminalView(frame: .zero)
|
||||
view.terminalDelegate = context.coordinator
|
||||
view.feed(text: SpikeGreeting.text)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: TerminalView, context: Context) {
|
||||
// 本地回显无外部状态可同步。
|
||||
}
|
||||
}
|
||||
|
||||
private enum SpikeGreeting {
|
||||
/// 行尾用 CRLF:PTY 语义下裸 LF 只下移一行不回车。
|
||||
static let text = "SwiftTerm local-echo spike (T-iOS-2)\r\n"
|
||||
+ "Typed bytes are echoed locally; CR -> CRLF.\r\n"
|
||||
+ "真机清单: 键盘弹出 / 中文 IME / accessory esc·ctrl / 文本选择\r\n\r\n> "
|
||||
}
|
||||
|
||||
/// 本地回显 delegate:SwiftTerm 要"发给宿主"的每个字节原样喂回终端。
|
||||
/// Enter 产生的 CR(0x0D)补一个 LF,光标才会回行首并换行。
|
||||
@MainActor
|
||||
private final class LocalEchoCoordinator: NSObject, @preconcurrency TerminalViewDelegate {
|
||||
private static let carriageReturn: UInt8 = 0x0D
|
||||
private static let lineFeed: UInt8 = 0x0A
|
||||
|
||||
func send(source: TerminalView, data: ArraySlice<UInt8>) {
|
||||
var echoed: [UInt8] = []
|
||||
echoed.reserveCapacity(data.count + 1)
|
||||
for byte in data {
|
||||
echoed.append(byte)
|
||||
if byte == Self.carriageReturn {
|
||||
echoed.append(Self.lineFeed)
|
||||
}
|
||||
}
|
||||
source.feed(byteArray: echoed[...])
|
||||
}
|
||||
|
||||
func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {}
|
||||
func setTerminalTitle(source: TerminalView, title: String) {}
|
||||
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
|
||||
func scrolled(source: TerminalView, position: Double) {}
|
||||
func requestOpenLink(source: TerminalView, link: String, params: [String: String]) {}
|
||||
func clipboardCopy(source: TerminalView, content: Data) {}
|
||||
func rangeChanged(source: TerminalView, startY: Int, endY: Int) {}
|
||||
// bell / iTermContent 用 SwiftTerm 的默认实现。
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SpikeTerminalScreen()
|
||||
}
|
||||
24
ios/App/WebTerm/WebTermApp.swift
Normal file
24
ios/App/WebTerm/WebTermApp.swift
Normal file
@@ -0,0 +1,24 @@
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-1 scaffold: empty-window placeholder. Navigation assembly
|
||||
/// (Pairing → SessionList → Terminal) is wired in T-iOS-15.
|
||||
@main
|
||||
struct WebTermApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
HelloPlaceholderView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HelloPlaceholderView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "terminal")
|
||||
.font(.largeTitle)
|
||||
Text("Hello WebTerm")
|
||||
.font(.title2)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
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]
|
||||
)
|
||||
24
ios/Packages/APIClient/Package.swift
Normal file
24
ios/Packages/APIClient/Package.swift
Normal file
@@ -0,0 +1,24 @@
|
||||
// swift-tools-version: 6.0
|
||||
// T-iOS-1 scaffold shell. APIClient / Endpoints / Models / PairingProbe /
|
||||
// PairingError land in T-iOS-8 (P1 increments via T-iOS-38, single owner).
|
||||
// Dependency direction is strictly downward: APIClient → WireProtocol only.
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "APIClient",
|
||||
platforms: [.iOS(.v17), .macOS(.v14)],
|
||||
products: [
|
||||
.library(name: "APIClient", targets: ["APIClient"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../WireProtocol"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "APIClient",
|
||||
dependencies: [.product(name: "WireProtocol", package: "WireProtocol")]
|
||||
),
|
||||
.testTarget(name: "APIClientTests", dependencies: ["APIClient"]),
|
||||
],
|
||||
swiftLanguageModes: [.v6]
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-1 scaffold placeholder.
|
||||
///
|
||||
/// Real modules land in T-iOS-8: APIClient, Endpoints, Models, PairingProbe,
|
||||
/// PairingError (Origin iff-G stamping is a frozen security semantic, §3.4/§5.1).
|
||||
public enum APIClientPackage {
|
||||
/// Package identity marker consumed by the scaffold smoke test.
|
||||
public static let packageName = "APIClient"
|
||||
/// Proves the single allowed dependency edge (APIClient → WireProtocol) compiles.
|
||||
public static let contractPackageName = WireProtocolPackage.packageName
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Testing
|
||||
import APIClient
|
||||
|
||||
@Test("脚手架:APIClient 包可编译且依赖边 APIClient→WireProtocol 成立")
|
||||
func apiClientPackageIsImportableAndDependsOnWireProtocol() {
|
||||
// Arrange / Act
|
||||
let name = APIClientPackage.packageName
|
||||
let contract = APIClientPackage.contractPackageName
|
||||
|
||||
// Assert
|
||||
#expect(name == "APIClient")
|
||||
#expect(contract == "WireProtocol")
|
||||
}
|
||||
24
ios/Packages/HostRegistry/Package.swift
Normal file
24
ios/Packages/HostRegistry/Package.swift
Normal file
@@ -0,0 +1,24 @@
|
||||
// swift-tools-version: 6.0
|
||||
// T-iOS-1 scaffold shell. Host / HostStore / KeychainHostStore / SecItemShim /
|
||||
// InMemoryHostStore / LastSessionStore land in T-iOS-7.
|
||||
// Dependency direction is strictly downward: HostRegistry → WireProtocol only.
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "HostRegistry",
|
||||
platforms: [.iOS(.v17), .macOS(.v14)],
|
||||
products: [
|
||||
.library(name: "HostRegistry", targets: ["HostRegistry"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../WireProtocol"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "HostRegistry",
|
||||
dependencies: [.product(name: "WireProtocol", package: "WireProtocol")]
|
||||
),
|
||||
.testTarget(name: "HostRegistryTests", dependencies: ["HostRegistry"]),
|
||||
],
|
||||
swiftLanguageModes: [.v6]
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-1 scaffold placeholder.
|
||||
///
|
||||
/// Real modules land in T-iOS-7: Host, HostStore, KeychainHostStore (via
|
||||
/// SecItemShim seam), InMemoryHostStore, LastSessionStore.
|
||||
public enum HostRegistryPackage {
|
||||
/// Package identity marker consumed by the scaffold smoke test.
|
||||
public static let packageName = "HostRegistry"
|
||||
/// Proves the single allowed dependency edge (HostRegistry → WireProtocol) compiles.
|
||||
public static let contractPackageName = WireProtocolPackage.packageName
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Testing
|
||||
import HostRegistry
|
||||
|
||||
@Test("脚手架:HostRegistry 包可编译且依赖边 HostRegistry→WireProtocol 成立")
|
||||
func hostRegistryPackageIsImportableAndDependsOnWireProtocol() {
|
||||
// Arrange / Act
|
||||
let name = HostRegistryPackage.packageName
|
||||
let contract = HostRegistryPackage.contractPackageName
|
||||
|
||||
// Assert
|
||||
#expect(name == "HostRegistry")
|
||||
#expect(contract == "WireProtocol")
|
||||
}
|
||||
24
ios/Packages/SessionCore/Package.swift
Normal file
24
ios/Packages/SessionCore/Package.swift
Normal file
@@ -0,0 +1,24 @@
|
||||
// swift-tools-version: 6.0
|
||||
// T-iOS-1 scaffold shell. SessionEngine / ReconnectMachine / PingScheduler /
|
||||
// GateState / AwayDigest / URLSessionTermTransport land in W1–W2 tasks.
|
||||
// Dependency direction is strictly downward: SessionCore → WireProtocol only.
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "SessionCore",
|
||||
platforms: [.iOS(.v17), .macOS(.v14)],
|
||||
products: [
|
||||
.library(name: "SessionCore", targets: ["SessionCore"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../WireProtocol"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "SessionCore",
|
||||
dependencies: [.product(name: "WireProtocol", package: "WireProtocol")]
|
||||
),
|
||||
.testTarget(name: "SessionCoreTests", dependencies: ["SessionCore"]),
|
||||
],
|
||||
swiftLanguageModes: [.v6]
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-1 scaffold placeholder.
|
||||
///
|
||||
/// Real modules land in their owning tasks: ReconnectMachine + PingScheduler
|
||||
/// (T-iOS-5), GateState + AwayDigest (T-iOS-6), URLSessionTermTransport
|
||||
/// (T-iOS-9), SessionEngine + SessionEvent (T-iOS-10), KeyByteMap (T-iOS-11).
|
||||
public enum SessionCorePackage {
|
||||
/// Package identity marker consumed by the scaffold smoke test.
|
||||
public static let packageName = "SessionCore"
|
||||
/// Proves the single allowed dependency edge (SessionCore → WireProtocol) compiles.
|
||||
public static let contractPackageName = WireProtocolPackage.packageName
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Testing
|
||||
import SessionCore
|
||||
|
||||
@Test("脚手架:SessionCore 包可编译且依赖边 SessionCore→WireProtocol 成立")
|
||||
func sessionCorePackageIsImportableAndDependsOnWireProtocol() {
|
||||
// Arrange / Act
|
||||
let name = SessionCorePackage.packageName
|
||||
let contract = SessionCorePackage.contractPackageName
|
||||
|
||||
// Assert
|
||||
#expect(name == "SessionCore")
|
||||
#expect(contract == "WireProtocol")
|
||||
}
|
||||
21
ios/Packages/TestSupport/Package.swift
Normal file
21
ios/Packages/TestSupport/Package.swift
Normal file
@@ -0,0 +1,21 @@
|
||||
// swift-tools-version: 6.0
|
||||
// T-iOS-4: test doubles leaned on by every W1-W3 task. Depends ONLY on
|
||||
// WireProtocol (the frozen contract) — never on SessionCore/HostRegistry/
|
||||
// APIClient, so it compiles in W0 and leaf packages stay decoupled (plan §6).
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "TestSupport",
|
||||
platforms: [.iOS(.v17), .macOS(.v14)],
|
||||
products: [
|
||||
.library(name: "TestSupport", targets: ["TestSupport"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../WireProtocol"),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "TestSupport", dependencies: ["WireProtocol"]),
|
||||
.testTarget(name: "TestSupportTests", dependencies: ["TestSupport"]),
|
||||
],
|
||||
swiftLanguageModes: [.v6]
|
||||
)
|
||||
176
ios/Packages/TestSupport/Sources/TestSupport/FakeClock.swift
Normal file
176
ios/Packages/TestSupport/Sources/TestSupport/FakeClock.swift
Normal file
@@ -0,0 +1,176 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Manually advanced `Clock<Duration>` double (T-iOS-4). Drives
|
||||
/// `ReconnectMachine` / `PingScheduler` / `SessionEngine` tests with ZERO
|
||||
/// real-time waiting: code under test suspends in `sleep(until:tolerance:)`
|
||||
/// and only resumes when the test calls `advance(by:)` past its deadline.
|
||||
///
|
||||
/// Deterministic hand-off pattern (no yield-loops, no real sleeps):
|
||||
///
|
||||
/// let task = Task { try await scheduler.run() } // sleeps on this clock
|
||||
/// await clock.waitForSleepers(count: 1) // sleeper is parked
|
||||
/// clock.advance(by: Tunables.pingInterval) // fire it
|
||||
///
|
||||
/// Sendable: all state lives behind one `OSAllocatedUnfairLock`; continuations
|
||||
/// are always resumed OUTSIDE the lock. Cancelling a sleeping task throws
|
||||
/// `CancellationError` out of `sleep`, matching `ContinuousClock` semantics.
|
||||
public final class FakeClock: Clock, Sendable {
|
||||
/// Instant = duration offset from the clock's zero epoch.
|
||||
public struct Instant: InstantProtocol, Sendable, Hashable, Comparable {
|
||||
public typealias Duration = Swift.Duration
|
||||
|
||||
public let offset: Swift.Duration
|
||||
|
||||
public init(offset: Swift.Duration = .zero) {
|
||||
self.offset = offset
|
||||
}
|
||||
|
||||
public func advanced(by duration: Swift.Duration) -> Instant {
|
||||
Instant(offset: offset + duration)
|
||||
}
|
||||
|
||||
public func duration(to other: Instant) -> Swift.Duration {
|
||||
other.offset - offset
|
||||
}
|
||||
|
||||
public static func < (lhs: Instant, rhs: Instant) -> Bool {
|
||||
lhs.offset < rhs.offset
|
||||
}
|
||||
}
|
||||
|
||||
private struct Sleeper {
|
||||
let deadline: Instant
|
||||
let continuation: CheckedContinuation<Void, any Error>
|
||||
}
|
||||
|
||||
private struct Waiter {
|
||||
let targetCount: Int
|
||||
let continuation: CheckedContinuation<Void, Never>
|
||||
}
|
||||
|
||||
private struct State {
|
||||
var now: Instant
|
||||
var sleepers: [UUID: Sleeper] = [:]
|
||||
var waiters: [UUID: Waiter] = [:]
|
||||
}
|
||||
|
||||
private enum SleepRegistration {
|
||||
case alreadyDue
|
||||
case cancelled
|
||||
case sleeping(satisfiedWaiters: [Waiter])
|
||||
}
|
||||
|
||||
private let state: OSAllocatedUnfairLock<State>
|
||||
|
||||
public init(now: Instant = Instant()) {
|
||||
state = OSAllocatedUnfairLock(initialState: State(now: now))
|
||||
}
|
||||
|
||||
// MARK: - Clock conformance
|
||||
|
||||
public var now: Instant {
|
||||
state.withLock { $0.now }
|
||||
}
|
||||
|
||||
public var minimumResolution: Swift.Duration { .zero }
|
||||
|
||||
public func sleep(until deadline: Instant, tolerance: Swift.Duration? = nil) async throws {
|
||||
let id = UUID()
|
||||
try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
|
||||
registerSleeper(id: id, deadline: deadline, continuation: continuation)
|
||||
}
|
||||
} onCancel: {
|
||||
let sleeper = state.withLock { $0.sleepers.removeValue(forKey: id) }
|
||||
sleeper?.continuation.resume(throwing: CancellationError())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Manual control (test side)
|
||||
|
||||
/// Move time forward and synchronously resume every sleeper whose deadline
|
||||
/// is now due (in deadline order) — resumed outside the lock.
|
||||
public func advance(by duration: Swift.Duration) {
|
||||
precondition(duration >= .zero, "FakeClock cannot move backwards")
|
||||
let due: [Sleeper] = state.withLock { state in
|
||||
state.now = state.now.advanced(by: duration)
|
||||
let dueNow = state.sleepers.filter { $0.value.deadline <= state.now }
|
||||
for key in dueNow.keys {
|
||||
state.sleepers.removeValue(forKey: key)
|
||||
}
|
||||
return dueNow.values.sorted { $0.deadline < $1.deadline }
|
||||
}
|
||||
for sleeper in due {
|
||||
sleeper.continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of tasks currently parked in `sleep` — for test assertions.
|
||||
public var pendingSleeperCount: Int {
|
||||
state.withLock { $0.sleepers.count }
|
||||
}
|
||||
|
||||
/// Suspend until at least `count` sleepers are parked. This is the
|
||||
/// deterministic "task reached its sleep" barrier — never poll or
|
||||
/// real-sleep to wait for the code under test. Resumes immediately if
|
||||
/// already satisfied; on task cancellation it resumes without error.
|
||||
public func waitForSleepers(count: Int = 1) async {
|
||||
let id = UUID()
|
||||
await withTaskCancellationHandler {
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
registerWaiter(id: id, targetCount: count, continuation: continuation)
|
||||
}
|
||||
} onCancel: {
|
||||
let waiter = state.withLock { $0.waiters.removeValue(forKey: id) }
|
||||
waiter?.continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals (registration under the lock, resumption outside it)
|
||||
|
||||
private func registerSleeper(
|
||||
id: UUID,
|
||||
deadline: Instant,
|
||||
continuation: CheckedContinuation<Void, any Error>
|
||||
) {
|
||||
let registration: SleepRegistration = state.withLock { state in
|
||||
if Task.isCancelled { return .cancelled }
|
||||
guard deadline > state.now else { return .alreadyDue }
|
||||
state.sleepers[id] = Sleeper(deadline: deadline, continuation: continuation)
|
||||
return .sleeping(satisfiedWaiters: Self.takeSatisfiedWaiters(&state))
|
||||
}
|
||||
switch registration {
|
||||
case .alreadyDue:
|
||||
continuation.resume()
|
||||
case .cancelled:
|
||||
continuation.resume(throwing: CancellationError())
|
||||
case .sleeping(let satisfiedWaiters):
|
||||
for waiter in satisfiedWaiters {
|
||||
waiter.continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func registerWaiter(
|
||||
id: UUID,
|
||||
targetCount: Int,
|
||||
continuation: CheckedContinuation<Void, Never>
|
||||
) {
|
||||
let isAlreadySatisfied: Bool = state.withLock { state in
|
||||
if Task.isCancelled || state.sleepers.count >= targetCount { return true }
|
||||
state.waiters[id] = Waiter(targetCount: targetCount, continuation: continuation)
|
||||
return false
|
||||
}
|
||||
if isAlreadySatisfied {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private static func takeSatisfiedWaiters(_ state: inout State) -> [Waiter] {
|
||||
let satisfiedKeys = state.waiters
|
||||
.filter { $0.value.targetCount <= state.sleepers.count }
|
||||
.map { $0.key }
|
||||
return satisfiedKeys.compactMap { state.waiters.removeValue(forKey: $0) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
/// Errors `FakeHTTPTransport` produces on its own (Equatable for exact
|
||||
/// `#expect(throws:)` assertions).
|
||||
public enum FakeHTTPTransportError: Error, Equatable, Sendable {
|
||||
/// `send` was called for a URL/method with no queued response — the test
|
||||
/// forgot to script it. Loud and identifying, never a silent hang.
|
||||
case noQueuedResponse(method: String, url: URL)
|
||||
/// The request had no URL at all (malformed test input).
|
||||
case requestMissingURL
|
||||
/// `HTTPURLResponse` refused the scripted status/headers (should not
|
||||
/// happen with sane inputs; surfaced explicitly rather than force-unwrapped).
|
||||
case responseConstructionFailed(url: URL)
|
||||
}
|
||||
|
||||
/// In-memory `HTTPTransport` double (T-iOS-4). `APIClient` cannot tell it
|
||||
/// apart from the `URLSession`-backed implementation (plan §3.4).
|
||||
///
|
||||
/// - **Queue responses per URL/method** (FIFO per route): `queueSuccess` /
|
||||
/// `queueFailure`. An unqueued route throws `.noQueuedResponse` immediately.
|
||||
/// - **Record requests verbatim, headers included** — so Origin-iff-G tests
|
||||
/// (plan §3.4 铁律) can assert exactly which requests carried `Origin`.
|
||||
public actor FakeHTTPTransport: HTTPTransport {
|
||||
/// Route identity: HTTP method (uppercased) + exact URL.
|
||||
public struct RouteKey: Hashable, Sendable {
|
||||
public let method: String
|
||||
public let url: URL
|
||||
|
||||
public init(method: String, url: URL) {
|
||||
self.method = method.uppercased()
|
||||
self.url = url
|
||||
}
|
||||
}
|
||||
|
||||
private enum QueuedResult {
|
||||
case success(status: Int, headers: [String: String], body: Data)
|
||||
case failure(any Error)
|
||||
}
|
||||
|
||||
/// Default HTTP method for queueing/matching when a request omits one.
|
||||
public static let defaultMethod = "GET"
|
||||
/// Default scripted success status.
|
||||
public static let defaultOKStatus = 200
|
||||
private static let httpVersion = "HTTP/1.1"
|
||||
|
||||
private var queues: [RouteKey: [QueuedResult]] = [:]
|
||||
|
||||
/// Every request passed to `send`, in order, verbatim (URL, method,
|
||||
/// headers, body) — including ones that found no queued response.
|
||||
public private(set) var recordedRequests: [URLRequest] = []
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - Scripting
|
||||
|
||||
/// Queue one successful response for `method url` (FIFO per route).
|
||||
public func queueSuccess(
|
||||
method: String = FakeHTTPTransport.defaultMethod,
|
||||
url: URL,
|
||||
status: Int = FakeHTTPTransport.defaultOKStatus,
|
||||
headers: [String: String] = [:],
|
||||
body: Data = Data()
|
||||
) {
|
||||
enqueue(.success(status: status, headers: headers, body: body),
|
||||
for: RouteKey(method: method, url: url))
|
||||
}
|
||||
|
||||
/// Queue one transport-level failure for `method url` (e.g. connection
|
||||
/// refused → `PairingError.hostUnreachable` classification tests).
|
||||
public func queueFailure(
|
||||
method: String = FakeHTTPTransport.defaultMethod,
|
||||
url: URL,
|
||||
error: any Error
|
||||
) {
|
||||
enqueue(.failure(error), for: RouteKey(method: method, url: url))
|
||||
}
|
||||
|
||||
// MARK: - HTTPTransport
|
||||
|
||||
public func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
recordedRequests.append(request)
|
||||
guard let url = request.url else {
|
||||
throw FakeHTTPTransportError.requestMissingURL
|
||||
}
|
||||
let key = RouteKey(method: request.httpMethod ?? Self.defaultMethod, url: url)
|
||||
guard var queue = queues[key], !queue.isEmpty else {
|
||||
throw FakeHTTPTransportError.noQueuedResponse(method: key.method, url: url)
|
||||
}
|
||||
let next = queue.removeFirst()
|
||||
queues[key] = queue
|
||||
switch next {
|
||||
case .failure(let error):
|
||||
throw error
|
||||
case .success(let status, let headers, let body):
|
||||
return (body, try Self.makeResponse(url: url, status: status, headers: headers))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private func enqueue(_ result: QueuedResult, for key: RouteKey) {
|
||||
queues[key, default: []].append(result)
|
||||
}
|
||||
|
||||
private static func makeResponse(
|
||||
url: URL,
|
||||
status: Int,
|
||||
headers: [String: String]
|
||||
) throws -> HTTPURLResponse {
|
||||
guard let response = HTTPURLResponse(
|
||||
url: url, statusCode: status, httpVersion: httpVersion, headerFields: headers
|
||||
) else {
|
||||
throw FakeHTTPTransportError.responseConstructionFailed(url: url)
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
161
ios/Packages/TestSupport/Sources/TestSupport/FakeTransport.swift
Normal file
161
ios/Packages/TestSupport/Sources/TestSupport/FakeTransport.swift
Normal file
@@ -0,0 +1,161 @@
|
||||
import WireProtocol
|
||||
|
||||
/// Errors `FakeTransport` produces on its own (Equatable so tests can
|
||||
/// `#expect(throws:)` on exact values).
|
||||
public enum FakeTransportError: Error, Equatable, Sendable {
|
||||
/// Default error thrown by a scripted connect failure.
|
||||
case scriptedConnectFailure
|
||||
/// `send` was called on a connection that already terminated
|
||||
/// (client `close()`, server finish, or server error).
|
||||
case sendAfterClose
|
||||
}
|
||||
|
||||
/// In-memory `TermTransport` double (T-iOS-4). `SessionEngine` cannot tell it
|
||||
/// apart from `URLSessionTermTransport` — same frozen contract (plan §3.2).
|
||||
///
|
||||
/// Capabilities:
|
||||
/// - **Scripted connect failures**: `scriptConnectFailure(_:)` queues errors
|
||||
/// thrown by subsequent `connect` calls, FIFO (reconnect/backoff tests).
|
||||
/// - **Manual frame injection**: `emit(frame:)` / `emitError(_:)` /
|
||||
/// `finishFrames()` drive the latest connection's `frames` stream. With no
|
||||
/// live connection the event is queued and flushed, in order, into the next
|
||||
/// successful `connect` — so tests can script server behavior up front
|
||||
/// (mirrors attach→replay) and nothing is ever silently dropped.
|
||||
/// - **Recording**: every connect attempt (including scripted failures), every
|
||||
/// sent frame (per connection and flattened), and every `close()` call.
|
||||
///
|
||||
/// Async-safe by construction (actor), Sendable, zero real-time sleeps:
|
||||
/// injected frames buffer unboundedly until the consumer iterates.
|
||||
public actor FakeTransport: TermTransport {
|
||||
private enum ServerEvent {
|
||||
case frame(String)
|
||||
case failure(any Error)
|
||||
case finish
|
||||
}
|
||||
|
||||
private struct ConnectionState {
|
||||
let continuation: AsyncThrowingStream<String, any Error>.Continuation
|
||||
var sentFrames: [String] = []
|
||||
var isTerminated = false
|
||||
}
|
||||
|
||||
private var scriptedConnectFailures: [any Error] = []
|
||||
private var pendingEvents: [ServerEvent] = []
|
||||
private var connections: [ConnectionState] = []
|
||||
|
||||
/// Every endpoint `connect` was called with, in order — scripted failures
|
||||
/// included (so backoff tests can count attempts).
|
||||
public private(set) var connectAttempts: [HostEndpoint] = []
|
||||
/// Total `close()` calls across all connections (double-close included).
|
||||
public private(set) var closeCallCount = 0
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - Scripting
|
||||
|
||||
/// Queue an error for the next `connect` call (FIFO across calls).
|
||||
public func scriptConnectFailure(
|
||||
_ error: any Error = FakeTransportError.scriptedConnectFailure
|
||||
) {
|
||||
scriptedConnectFailures.append(error)
|
||||
}
|
||||
|
||||
// MARK: - TermTransport
|
||||
|
||||
public func connect(to endpoint: HostEndpoint) async throws -> TransportConnection {
|
||||
connectAttempts.append(endpoint)
|
||||
if !scriptedConnectFailures.isEmpty {
|
||||
throw scriptedConnectFailures.removeFirst()
|
||||
}
|
||||
let (stream, continuation) = AsyncThrowingStream<String, any Error>.makeStream()
|
||||
let index = connections.count
|
||||
connections.append(ConnectionState(continuation: continuation))
|
||||
flushPendingEvents(into: index)
|
||||
return TransportConnection(
|
||||
frames: stream,
|
||||
send: { [self] frame in try await self.recordSend(frame, connection: index) },
|
||||
close: { [self] in await self.recordClose(connection: index) }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Manual injection (server side of the wire)
|
||||
|
||||
/// Yield one server JSON text frame into the latest connection
|
||||
/// (or queue it for the next connect — see type doc).
|
||||
public func emit(frame: String) {
|
||||
deliver(.frame(frame))
|
||||
}
|
||||
|
||||
/// Terminate the latest connection's stream with `error`
|
||||
/// (transport failure — the "stream throw" half of the contract).
|
||||
public func emitError(_ error: any Error) {
|
||||
deliver(.failure(error))
|
||||
}
|
||||
|
||||
/// Finish the latest connection's stream cleanly
|
||||
/// (server close — the "stream finish" half of the contract).
|
||||
public func finishFrames() {
|
||||
deliver(.finish)
|
||||
}
|
||||
|
||||
// MARK: - Recorded traffic
|
||||
|
||||
/// All frames sent by the client, flattened in connection order.
|
||||
public var sentFrames: [String] { connections.flatMap(\.sentFrames) }
|
||||
|
||||
/// Frames sent by the client, grouped per successful connection
|
||||
/// (reconnect tests assert the re-attach frame landed on connection 1).
|
||||
public var sentFramesByConnection: [[String]] { connections.map(\.sentFrames) }
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private func deliver(_ event: ServerEvent) {
|
||||
guard let index = liveConnectionIndex() else {
|
||||
pendingEvents.append(event)
|
||||
return
|
||||
}
|
||||
apply(event, to: index)
|
||||
}
|
||||
|
||||
private func liveConnectionIndex() -> Int? {
|
||||
guard let last = connections.indices.last, !connections[last].isTerminated else {
|
||||
return nil
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
private func apply(_ event: ServerEvent, to index: Int) {
|
||||
switch event {
|
||||
case .frame(let frame):
|
||||
connections[index].continuation.yield(frame)
|
||||
case .failure(let error):
|
||||
connections[index].isTerminated = true
|
||||
connections[index].continuation.finish(throwing: error)
|
||||
case .finish:
|
||||
connections[index].isTerminated = true
|
||||
connections[index].continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
private func flushPendingEvents(into index: Int) {
|
||||
let events = pendingEvents
|
||||
pendingEvents = []
|
||||
for event in events {
|
||||
apply(event, to: index)
|
||||
}
|
||||
}
|
||||
|
||||
private func recordSend(_ frame: String, connection index: Int) throws {
|
||||
guard !connections[index].isTerminated else {
|
||||
throw FakeTransportError.sendAfterClose
|
||||
}
|
||||
connections[index].sentFrames.append(frame)
|
||||
}
|
||||
|
||||
private func recordClose(connection index: Int) {
|
||||
closeCallCount += 1
|
||||
guard !connections[index].isTerminated else { return }
|
||||
connections[index].isTerminated = true
|
||||
connections[index].continuation.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Testing
|
||||
@testable import TestSupport
|
||||
|
||||
@Suite("FakeClock")
|
||||
struct FakeClockTests {
|
||||
private static let sleepDuration: Duration = .seconds(25)
|
||||
private static let partialAdvance: Duration = .seconds(10)
|
||||
private static let remainingAdvance: Duration = .seconds(15)
|
||||
|
||||
@Test("sleeper wakes only after the clock is manually advanced past its deadline")
|
||||
func sleeperWakesOnlyAfterAdvancePastDeadline() async throws {
|
||||
// Arrange
|
||||
let clock = FakeClock()
|
||||
let deadline = clock.now.advanced(by: Self.sleepDuration)
|
||||
let sleeper = Task {
|
||||
try await clock.sleep(until: deadline, tolerance: nil)
|
||||
}
|
||||
await clock.waitForSleepers(count: 1)
|
||||
|
||||
// Act: advance short of the deadline — the sleeper must stay asleep.
|
||||
clock.advance(by: Self.partialAdvance)
|
||||
#expect(clock.pendingSleeperCount == 1)
|
||||
|
||||
// Act: advance up to the deadline — the sleeper must wake.
|
||||
clock.advance(by: Self.remainingAdvance)
|
||||
try await sleeper.value
|
||||
|
||||
// Assert: zero real-time waiting, purely manual time.
|
||||
#expect(clock.pendingSleeperCount == 0)
|
||||
#expect(clock.now == FakeClock.Instant(offset: Self.sleepDuration))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import TestSupport
|
||||
|
||||
@Suite("FakeHTTPTransport")
|
||||
struct FakeHTTPTransportTests {
|
||||
private static let originValue = "http://192.168.1.5:3000"
|
||||
|
||||
@Test("replays queued responses per URL/method, records requests with headers, and fails loudly when unqueued")
|
||||
func replaysQueuedResponsesAndRecordsRequests() async throws {
|
||||
// Arrange
|
||||
let transport = FakeHTTPTransport()
|
||||
let listURL = try #require(URL(string: "http://192.168.1.5:3000/live-sessions"))
|
||||
let unqueuedURL = try #require(URL(string: "http://192.168.1.5:3000/other"))
|
||||
let body = Data("[]".utf8)
|
||||
await transport.queueSuccess(url: listURL, body: body)
|
||||
|
||||
var request = URLRequest(url: listURL)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue(Self.originValue, forHTTPHeaderField: "Origin")
|
||||
|
||||
// Act
|
||||
let (data, response) = try await transport.send(request)
|
||||
|
||||
// Assert: the queued response came back for that URL/method.
|
||||
#expect(data == body)
|
||||
#expect(response.statusCode == FakeHTTPTransport.defaultOKStatus)
|
||||
#expect(response.url == listURL)
|
||||
|
||||
// Assert: the request was recorded verbatim, headers included
|
||||
// (this is what lets APIClient tests assert Origin-iff-G, plan §3.4).
|
||||
let recorded = await transport.recordedRequests
|
||||
#expect(recorded.count == 1)
|
||||
#expect(recorded.first?.url == listURL)
|
||||
#expect(recorded.first?.value(forHTTPHeaderField: "Origin") == Self.originValue)
|
||||
|
||||
// Assert: an unqueued route throws an explicit, identifying error.
|
||||
await #expect(throws: FakeHTTPTransportError.noQueuedResponse(method: "GET", url: unqueuedURL)) {
|
||||
_ = try await transport.send(URLRequest(url: unqueuedURL))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import TestSupport
|
||||
|
||||
@Suite("FakeTransport")
|
||||
struct FakeTransportTests {
|
||||
@Test("scripts connect failures, delivers injected frames, records sends and closes")
|
||||
func scriptsFailuresDeliversFramesAndRecordsTraffic() async throws {
|
||||
// Arrange
|
||||
let transport = FakeTransport()
|
||||
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
let attachFrame = #"{"type":"attach","sessionId":null}"#
|
||||
|
||||
// Act + Assert: a scripted failure makes the next connect throw it.
|
||||
await transport.scriptConnectFailure()
|
||||
await #expect(throws: FakeTransportError.scriptedConnectFailure) {
|
||||
_ = try await transport.connect(to: endpoint)
|
||||
}
|
||||
|
||||
// Act: connect for real, send one frame, inject one frame, close cleanly.
|
||||
let connection = try await transport.connect(to: endpoint)
|
||||
try await connection.send(attachFrame)
|
||||
await transport.emit(frame: "server-frame-1")
|
||||
await transport.finishFrames()
|
||||
|
||||
var received: [String] = []
|
||||
for try await frame in connection.frames {
|
||||
received.append(frame)
|
||||
}
|
||||
await connection.close()
|
||||
|
||||
// Assert: injected frames arrived in order and ended with a clean finish.
|
||||
#expect(received == ["server-frame-1"])
|
||||
// Assert: the double recorded everything the client did.
|
||||
#expect(await transport.sentFrames == [attachFrame])
|
||||
#expect(await transport.closeCallCount == 1)
|
||||
#expect(await transport.connectAttempts == [endpoint, endpoint])
|
||||
}
|
||||
}
|
||||
17
ios/Packages/WireProtocol/Package.swift
Normal file
17
ios/Packages/WireProtocol/Package.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
// swift-tools-version: 6.0
|
||||
// T-iOS-1 scaffold shell. The frozen wire contract (types + Tunables + shared
|
||||
// I/O boundary types) lands in T-iOS-3, which owns ios/Packages/WireProtocol/**.
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "WireProtocol",
|
||||
platforms: [.iOS(.v17), .macOS(.v14)],
|
||||
products: [
|
||||
.library(name: "WireProtocol", targets: ["WireProtocol"]),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "WireProtocol"),
|
||||
.testTarget(name: "WireProtocolTests", dependencies: ["WireProtocol"]),
|
||||
],
|
||||
swiftLanguageModes: [.v6]
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
/// Client → server WS frames (frozen contract, plan §3.1; mirrors
|
||||
/// `src/types.ts:87-92` `ClientMessage`). Immutable value type.
|
||||
///
|
||||
/// Server-side validation the caller must respect (the server SILENTLY DISCARDS
|
||||
/// invalid frames, `src/protocol.ts:45-86` — no error reply comes back):
|
||||
/// - `attach` must be the FIRST frame on a connection (src/server.ts:707-711).
|
||||
/// - `resize` cols/rows must be integers in `WireConstants.resizeRange` (1...1000).
|
||||
/// - `attach.cwd`, when present, must be an absolute path (`Validation.isAbsoluteCwd`).
|
||||
/// - `input.data` is raw keyboard bytes, passed through verbatim — never filtered.
|
||||
public enum ClientMessage: Sendable, Equatable {
|
||||
/// First frame. `sessionId == nil` spawns a new session (encoded as an
|
||||
/// explicit JSON `"sessionId":null` — the key is REQUIRED by the server,
|
||||
/// src/protocol.ts:132-134). `cwd` = "new tab here" spawn directory (M6).
|
||||
case attach(sessionId: UUID?, cwd: String?)
|
||||
/// Raw keyboard bytes, verbatim (invariant #9 — no content filtering).
|
||||
case input(data: String)
|
||||
/// Own message type so the server can `ioctl(TIOCSWINSZ)` → SIGWINCH.
|
||||
case resize(cols: Int, rows: Int)
|
||||
/// Resolve a held permission gate with allow. `mode` is only meaningful for
|
||||
/// a `plan` gate and is encoded as a TOP-LEVEL `mode` key — the server's WS
|
||||
/// wiring re-parses the raw frame for it (src/server.ts:91-102).
|
||||
case approve(mode: ApproveMode?)
|
||||
/// Resolve a held permission gate with deny.
|
||||
case reject
|
||||
}
|
||||
|
||||
/// Permission mode written back when resolving a `plan` gate. Raw values mirror
|
||||
/// the server whitelist `PERMISSION_MODES` (src/server.ts:76 / src/types.ts:365).
|
||||
///
|
||||
/// Note: the plan-gate three-way UI only ever sends `.acceptEdits` / `.default`
|
||||
/// (mirroring public/tabs.ts:345-347). Raw `.auto` is gated by ALLOW_AUTO_MODE
|
||||
/// (default false) and is server-downgraded to `default` otherwise
|
||||
/// (src/server.ts:765-766); it is reserved for a future permission-mode switcher.
|
||||
public enum ApproveMode: String, Sendable, CaseIterable {
|
||||
case `default`
|
||||
case acceptEdits
|
||||
case plan
|
||||
case auto
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
/// Thin HTTP I/O boundary (frozen contract, plan §3.1). `APIClient` builds
|
||||
/// `URLRequest`s (Origin stamped iff the endpoint is a mutating `G` route,
|
||||
/// plan §3.4 铁律) and sends them through this seam; the production
|
||||
/// implementation wraps `URLSession`, `FakeHTTPTransport` (TestSupport)
|
||||
/// queues canned responses.
|
||||
public protocol HTTPTransport: Sendable {
|
||||
/// Perform one HTTP exchange. Implementations throw on transport-level
|
||||
/// failure; non-2xx statuses are returned, not thrown — classification
|
||||
/// (e.g. `PairingError`) is the caller's job.
|
||||
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
|
||||
/// A paired web-terminal host (frozen contract, plan §3.1). The SINGLE point of
|
||||
/// derivation for the `Origin` header and the WS URL — hand-assembling either
|
||||
/// anywhere else is a review CRITICAL (plan §5.1 / T-iOS-9 安全注).
|
||||
///
|
||||
/// Derivations are computed once at init and stored immutably:
|
||||
/// - `originHeader` = `<scheme>://<host>[:<port>]`, omitting the scheme's
|
||||
/// default port (http/80, https/443), scheme+host lowercased — identical to
|
||||
/// browser Origin serialization. The server normalises BOTH sides via
|
||||
/// `new URL()` before comparing protocol/hostname/port (src/http/origin.ts:31-51).
|
||||
/// - `wsURL` = same host+port, scheme http→ws / https→wss, path replaced by
|
||||
/// `WireConstants.wsPath`; query/fragment/credentials dropped.
|
||||
public struct HostEndpoint: Sendable, Equatable, Codable {
|
||||
/// The URL the user dialed: `http(s)://<host>[:<port>]`. Any path, query,
|
||||
/// fragment or credentials it carries are ignored by the derivations.
|
||||
public let baseURL: URL
|
||||
/// Derived WS endpoint (`ws(s)://…/term`). Stored at init; contract-wise a
|
||||
/// read-only property, per plan §3.1.
|
||||
public let wsURL: URL
|
||||
/// Derived `Origin` header value — see type doc. Never hand-assemble.
|
||||
public let originHeader: String
|
||||
|
||||
private static let wsSchemeByHTTPScheme = ["http": "ws", "https": "wss"]
|
||||
private static let defaultPortByScheme = ["http": 80, "https": 443]
|
||||
|
||||
/// Validating init: the URL must be http(s) with a non-empty host, else nil
|
||||
/// (QR-scan payloads are untrusted external input — reject early, plan §5).
|
||||
public init?(baseURL: URL) {
|
||||
guard let components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let wsScheme = Self.wsSchemeByHTTPScheme[scheme],
|
||||
let rawHost = components.host, !rawHost.isEmpty,
|
||||
let wsURL = Self.deriveWSURL(from: components, wsScheme: wsScheme)
|
||||
else { return nil }
|
||||
|
||||
self.baseURL = baseURL
|
||||
self.wsURL = wsURL
|
||||
self.originHeader = Self.deriveOrigin(
|
||||
scheme: scheme, host: rawHost.lowercased(), port: components.port
|
||||
)
|
||||
}
|
||||
|
||||
private static func deriveOrigin(scheme: String, host: String, port: Int?) -> String {
|
||||
// IPv6 literals need brackets in an Origin. URLComponents.host has
|
||||
// returned them both bare and pre-bracketed across Foundation versions —
|
||||
// wrap idempotently.
|
||||
let needsBrackets = host.contains(":") && !host.hasPrefix("[")
|
||||
let serializedHost = needsBrackets ? "[\(host)]" : host
|
||||
guard let port, port != defaultPortByScheme[scheme] else {
|
||||
return "\(scheme)://\(serializedHost)"
|
||||
}
|
||||
return "\(scheme)://\(serializedHost):\(port)"
|
||||
}
|
||||
|
||||
private static func deriveWSURL(from components: URLComponents, wsScheme: String) -> URL? {
|
||||
var wsComponents = components
|
||||
wsComponents.scheme = wsScheme
|
||||
wsComponents.path = WireConstants.wsPath
|
||||
wsComponents.query = nil
|
||||
wsComponents.fragment = nil
|
||||
wsComponents.user = nil
|
||||
wsComponents.password = nil
|
||||
return wsComponents.url
|
||||
}
|
||||
|
||||
// MARK: - Codable (persisted via HostRegistry/Keychain — re-validate on decode)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case baseURL
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let url = try container.decode(URL.self, forKey: .baseURL)
|
||||
guard let endpoint = HostEndpoint(baseURL: url) else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .baseURL, in: container,
|
||||
debugDescription: "baseURL is not an http(s) URL with a host"
|
||||
)
|
||||
}
|
||||
self = endpoint
|
||||
}
|
||||
|
||||
public func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(baseURL, forKey: .baseURL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure static codec for the WS text-frame protocol (frozen contract, plan §3.1).
|
||||
/// NEVER throws in either direction:
|
||||
/// - `encode` is total — every `ClientMessage` has a JSON representation.
|
||||
/// - `decodeServer` mirrors the server's "invalid frames are silently discarded"
|
||||
/// resilience (src/protocol.ts:45-86, src/server.ts:698-701): malformed input → `nil`.
|
||||
public enum MessageCodec {
|
||||
// MARK: - encode (client → server)
|
||||
|
||||
/// Encode a `ClientMessage` as a JSON text frame accepted by the server's
|
||||
/// `parseClientMessage` (src/protocol.ts). String escaping matches
|
||||
/// `JSON.stringify` byte-for-byte (short escapes for \b \t \n \f \r, and
|
||||
/// `\u00XX` lowercase-hex for other C0 control bytes) so a Swift client is
|
||||
/// indistinguishable from the web client on the wire.
|
||||
///
|
||||
/// Key shapes (each pinned by CodecRoundtripTests):
|
||||
/// - `attach` ALWAYS carries an explicit `sessionId` key — JSON `null` when
|
||||
/// nil (the server requires the key's presence, src/protocol.ts:132-134);
|
||||
/// UUIDs are lowercased (crypto.randomUUID style; SESSION_ID_RE is /i).
|
||||
/// - `approve` puts `mode` as a TOP-LEVEL key: the server's WS wiring
|
||||
/// re-parses the RAW frame for `obj['mode']` (src/server.ts:91-102).
|
||||
/// - `resize` emits bare integers; `input.data` is escaped verbatim.
|
||||
public static func encode(_ message: ClientMessage) -> String {
|
||||
switch message {
|
||||
case let .attach(sessionId, cwd):
|
||||
return encodeAttach(sessionId: sessionId, cwd: cwd)
|
||||
case let .input(data):
|
||||
return "{\"type\":\"input\",\"data\":\(jsonStringLiteral(data))}"
|
||||
case let .resize(cols, rows):
|
||||
return "{\"type\":\"resize\",\"cols\":\(cols),\"rows\":\(rows)}"
|
||||
case let .approve(mode):
|
||||
guard let mode else { return "{\"type\":\"approve\"}" }
|
||||
return "{\"type\":\"approve\",\"mode\":\"\(mode.rawValue)\"}"
|
||||
case .reject:
|
||||
return "{\"type\":\"reject\"}"
|
||||
}
|
||||
}
|
||||
|
||||
private static func encodeAttach(sessionId: UUID?, cwd: String?) -> String {
|
||||
let sessionIdJSON = sessionId.map { "\"\($0.uuidString.lowercased())\"" } ?? "null"
|
||||
let cwdPart = cwd.map { ",\"cwd\":\(jsonStringLiteral($0))" } ?? ""
|
||||
return "{\"type\":\"attach\",\"sessionId\":\(sessionIdJSON)\(cwdPart)}"
|
||||
}
|
||||
|
||||
/// JSON string literal with `JSON.stringify`-identical escaping.
|
||||
private static func jsonStringLiteral(_ value: String) -> String {
|
||||
var out = "\""
|
||||
for scalar in value.unicodeScalars {
|
||||
switch scalar {
|
||||
case "\"": out += "\\\""
|
||||
case "\\": out += "\\\\"
|
||||
case "\u{08}": out += "\\b"
|
||||
case "\t": out += "\\t"
|
||||
case "\n": out += "\\n"
|
||||
case "\u{0C}": out += "\\f"
|
||||
case "\r": out += "\\r"
|
||||
default:
|
||||
if scalar.value < 0x20 {
|
||||
out += String(format: "\\u%04x", Int(scalar.value))
|
||||
} else {
|
||||
out.unicodeScalars.append(scalar)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out + "\""
|
||||
}
|
||||
|
||||
// MARK: - decodeServer (server → client, untrusted)
|
||||
|
||||
/// Decode a server JSON text frame. Whitelist semantics, never throws:
|
||||
/// bad JSON, non-object frames, unknown `type`, or missing/wrong-typed
|
||||
/// REQUIRED fields → `nil` (drop the frame). Wrong-typed OPTIONAL fields
|
||||
/// are tolerated as absent (see per-case doc on `ServerMessage`).
|
||||
public static func decodeServer(_ text: String) -> ServerMessage? {
|
||||
guard let data = text.data(using: .utf8),
|
||||
let frame = try? JSONDecoder().decode(ServerFrame.self, from: data)
|
||||
else { return nil }
|
||||
return frame.message
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internal decoding scaffolding
|
||||
|
||||
/// Decodable shim so one JSONDecoder pass yields `ServerMessage?` without ever
|
||||
/// surfacing a throw for merely-malformed content (only structurally non-JSON
|
||||
/// input makes JSONDecoder itself throw, which `decodeServer` catches).
|
||||
private struct ServerFrame: Decodable {
|
||||
let message: ServerMessage?
|
||||
|
||||
private enum Keys: String, CodingKey {
|
||||
case type, sessionId, data, code, reason, status, detail, pending, gate, telemetry
|
||||
}
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
guard let container = try? decoder.container(keyedBy: Keys.self),
|
||||
let type = try? container.decode(String.self, forKey: .type)
|
||||
else {
|
||||
message = nil
|
||||
return
|
||||
}
|
||||
message = Self.decodeBody(type: type, from: container)
|
||||
}
|
||||
|
||||
private static func decodeBody(
|
||||
type: String, from container: KeyedDecodingContainer<Keys>
|
||||
) -> ServerMessage? {
|
||||
switch type {
|
||||
case "attached":
|
||||
return decodeAttached(container)
|
||||
case "output":
|
||||
guard let data = try? container.decode(String.self, forKey: .data) else { return nil }
|
||||
return .output(data: data)
|
||||
case "exit":
|
||||
return decodeExit(container)
|
||||
case "status":
|
||||
return decodeStatus(container)
|
||||
case "telemetry":
|
||||
guard let telemetry = try? container.decode(StatusTelemetry.self, forKey: .telemetry)
|
||||
else { return nil }
|
||||
return .telemetry(telemetry)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// `attached.sessionId` must pass the server's own SESSION_ID_RE (M7) —
|
||||
/// `UUID(uuidString:)` alone would accept non-v4 UUIDs.
|
||||
private static func decodeAttached(_ container: KeyedDecodingContainer<Keys>) -> ServerMessage? {
|
||||
guard let raw = try? container.decode(String.self, forKey: .sessionId),
|
||||
Validation.isValidSessionId(raw),
|
||||
let sessionId = UUID(uuidString: raw)
|
||||
else { return nil }
|
||||
return .attached(sessionId: sessionId)
|
||||
}
|
||||
|
||||
private static func decodeExit(_ container: KeyedDecodingContainer<Keys>) -> ServerMessage? {
|
||||
guard let code = try? container.decode(Int.self, forKey: .code) else { return nil }
|
||||
let reason = try? container.decode(String.self, forKey: .reason)
|
||||
return .exit(code: code, reason: reason)
|
||||
}
|
||||
|
||||
private static func decodeStatus(_ container: KeyedDecodingContainer<Keys>) -> ServerMessage? {
|
||||
guard let rawStatus = try? container.decode(String.self, forKey: .status),
|
||||
let status = ClaudeStatus(rawValue: rawStatus)
|
||||
else { return nil }
|
||||
let detail = try? container.decode(String.self, forKey: .detail)
|
||||
let pending = (try? container.decode(Bool.self, forKey: .pending)) ?? false
|
||||
let gate = (try? container.decode(String.self, forKey: .gate))
|
||||
.flatMap(GateKind.init(rawValue:))
|
||||
return .status(status, detail: detail, pending: pending, gate: gate)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import Foundation
|
||||
|
||||
/// Server → client WS frames (frozen contract, plan §3.1; mirrors
|
||||
/// `src/types.ts:109-120` `ServerMessage`). Decoded ONLY via
|
||||
/// `MessageCodec.decodeServer` — the server is an untrusted input source and
|
||||
/// malformed frames become `nil`, never a crash.
|
||||
public enum ServerMessage: Sendable, Equatable {
|
||||
/// Attach confirmation. ALWAYS adopt the server-issued id — attaching with
|
||||
/// an unknown UUID yields a fresh session id (src/session/manager.ts:108-174).
|
||||
case attached(sessionId: UUID)
|
||||
/// Opaque ANSI/UTF-8 bytes; ring-buffer replay and live stream share this shape.
|
||||
case output(data: String)
|
||||
/// Shell exit. `code == WireConstants.spawnFailedExitCode` (-1) means the
|
||||
/// spawn never succeeded (M4) and `reason` is required server-side.
|
||||
case exit(code: Int, reason: String?)
|
||||
/// Claude Code activity derived from hooks (H2/H3/B4). `pending` = a tool
|
||||
/// approval is held server-side; `gate` says which kind. Absent `pending`
|
||||
/// decodes as `false`; an unrecognized `gate` value decodes as `nil`
|
||||
/// (tolerated as absent so the pending signal is never lost).
|
||||
case status(ClaudeStatus, detail: String?, pending: Bool, gate: GateKind?)
|
||||
/// Latest statusLine telemetry broadcast (B2).
|
||||
case telemetry(StatusTelemetry)
|
||||
}
|
||||
|
||||
/// Mirrors `src/types.ts:97` `ClaudeStatus`. `unknown` = no hook signal yet;
|
||||
/// `stuck` (A5) = output silent past STUCK_TTL while not idle/exited.
|
||||
public enum ClaudeStatus: String, Sendable {
|
||||
case working, waiting, idle, unknown, stuck
|
||||
}
|
||||
|
||||
/// Mirrors `src/types.ts:101` `PermissionGate`. `plan` = ExitPlanMode three-way
|
||||
/// gate; `tool` = ordinary tool gate.
|
||||
public enum GateKind: String, Sendable {
|
||||
case tool, plan
|
||||
}
|
||||
|
||||
/// Mirrors `src/types.ts:406-416` `StatusTelemetry`: every metric optional,
|
||||
/// `at` (server receive time, ms since epoch) required. Decoding is tolerant —
|
||||
/// a wrong-typed OPTIONAL field is treated as absent (mirrors the server's own
|
||||
/// tolerant statusLine parsing); a missing/wrong-typed `at` fails the decode.
|
||||
public struct StatusTelemetry: Sendable, Equatable, Decodable {
|
||||
public let contextUsedPct: Double?
|
||||
public let costUsd: Double?
|
||||
public let linesAdded: Int?
|
||||
public let linesRemoved: Int?
|
||||
public let model: String?
|
||||
public let effort: String?
|
||||
public let pr: PrInfo?
|
||||
public let rate: RateInfo?
|
||||
/// Server receive timestamp (ms). REQUIRED — frames without it are dropped.
|
||||
public let at: Int
|
||||
|
||||
public init(
|
||||
contextUsedPct: Double? = nil,
|
||||
costUsd: Double? = nil,
|
||||
linesAdded: Int? = nil,
|
||||
linesRemoved: Int? = nil,
|
||||
model: String? = nil,
|
||||
effort: String? = nil,
|
||||
pr: PrInfo? = nil,
|
||||
rate: RateInfo? = nil,
|
||||
at: Int
|
||||
) {
|
||||
self.contextUsedPct = contextUsedPct
|
||||
self.costUsd = costUsd
|
||||
self.linesAdded = linesAdded
|
||||
self.linesRemoved = linesRemoved
|
||||
self.model = model
|
||||
self.effort = effort
|
||||
self.pr = pr
|
||||
self.rate = rate
|
||||
self.at = at
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case contextUsedPct, costUsd, linesAdded, linesRemoved
|
||||
case model, effort, pr, rate, at
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
// `at` is the only required field; its absence invalidates the frame.
|
||||
at = try container.decode(Int.self, forKey: .at)
|
||||
// Optional fields: `try?` treats wrong-typed values as absent (tolerant).
|
||||
contextUsedPct = try? container.decode(Double.self, forKey: .contextUsedPct)
|
||||
costUsd = try? container.decode(Double.self, forKey: .costUsd)
|
||||
linesAdded = try? container.decode(Int.self, forKey: .linesAdded)
|
||||
linesRemoved = try? container.decode(Int.self, forKey: .linesRemoved)
|
||||
model = try? container.decode(String.self, forKey: .model)
|
||||
effort = try? container.decode(String.self, forKey: .effort)
|
||||
pr = try? container.decode(PrInfo.self, forKey: .pr)
|
||||
rate = try? container.decode(RateInfo.self, forKey: .rate)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors `src/types.ts:413` `StatusTelemetry.pr`.
|
||||
public struct PrInfo: Sendable, Equatable, Decodable {
|
||||
public let number: Int
|
||||
public let url: String
|
||||
public let reviewState: String?
|
||||
|
||||
public init(number: Int, url: String, reviewState: String? = nil) {
|
||||
self.number = number
|
||||
self.url = url
|
||||
self.reviewState = reviewState
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors `src/types.ts:414` `StatusTelemetry.rate`.
|
||||
public struct RateInfo: Sendable, Equatable, Decodable {
|
||||
public let fiveHourPct: Double?
|
||||
public let sevenDayPct: Double?
|
||||
|
||||
public init(fiveHourPct: Double? = nil, sevenDayPct: Double? = nil) {
|
||||
self.fiveHourPct = fiveHourPct
|
||||
self.sevenDayPct = sevenDayPct
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/// The ONLY WS I/O boundary (frozen contract, plan §3.1/§3.2).
|
||||
/// `URLSessionTermTransport` (SessionCore, T-iOS-9) and `FakeTransport`
|
||||
/// (TestSupport, T-iOS-4) both implement this; `SessionEngine` cannot tell
|
||||
/// them apart.
|
||||
public protocol TermTransport: Sendable {
|
||||
/// Open a WS connection to `endpoint.wsURL`, stamping
|
||||
/// `Origin: endpoint.originHeader` on the upgrade (plan §5.1).
|
||||
func connect(to endpoint: HostEndpoint) async throws -> TransportConnection
|
||||
}
|
||||
|
||||
/// One live WS connection, as immutable capability handles (frozen contract).
|
||||
public struct TransportConnection: Sendable {
|
||||
/// Server JSON text frames, in arrival order. Stream finish = clean close;
|
||||
/// stream throw = transport error (the two are distinguishable, T-iOS-9).
|
||||
public let frames: AsyncThrowingStream<String, any Error>
|
||||
/// Send one client JSON text frame (produced by `MessageCodec.encode`).
|
||||
public let send: @Sendable (String) async throws -> Void
|
||||
/// Close the connection (client detach — the server-side PTY keeps running).
|
||||
public let close: @Sendable () async -> Void
|
||||
|
||||
public init(
|
||||
frames: AsyncThrowingStream<String, any Error>,
|
||||
send: @escaping @Sendable (String) async throws -> Void,
|
||||
close: @escaping @Sendable () async -> Void
|
||||
) {
|
||||
self.frames = frames
|
||||
self.send = send
|
||||
self.close = close
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
|
||||
/// One activity-timeline entry from `GET /live-sessions/:id/events` (frozen
|
||||
/// contract, plan §3.1; mirrors src/types.ts:428-433). The server derives
|
||||
/// `class` semantically (src/types.ts:423: tool/waiting/done/stuck/user) — the
|
||||
/// client never re-derives it from hook names. `class` stays a raw `String` so
|
||||
/// the SHAPE decodes even for future/unknown classes; consumers drop unknowns
|
||||
/// (use `decodeList`, which does exactly that).
|
||||
public struct TimelineEvent: Sendable, Equatable, Decodable {
|
||||
/// Server ingest timestamp (ms since epoch).
|
||||
public let at: Int
|
||||
/// Server-derived semantic class; see `knownClasses`.
|
||||
public let `class`: String
|
||||
/// Sanitized tool name (server-side: ≤200 chars, control chars stripped).
|
||||
public let toolName: String?
|
||||
/// Server-derived human phrase ("ran Bash", "edited 3 files").
|
||||
public let label: String
|
||||
|
||||
/// The server's `TimelineClass` union (src/types.ts:423).
|
||||
public static let knownClasses: Set<String> = ["tool", "waiting", "done", "stuck", "user"]
|
||||
|
||||
public init(at: Int, class className: String, toolName: String?, label: String) {
|
||||
self.at = at
|
||||
self.`class` = className
|
||||
self.toolName = toolName
|
||||
self.label = label
|
||||
}
|
||||
|
||||
/// True when `class` is one the server currently emits.
|
||||
public var hasKnownClass: Bool {
|
||||
Self.knownClasses.contains(`class`)
|
||||
}
|
||||
|
||||
/// Decode a `/events` response body. NEVER throws: the server is an
|
||||
/// untrusted input source — malformed entries and unknown-class entries are
|
||||
/// silently dropped; a non-array body yields `[]`.
|
||||
public static func decodeList(from data: Data) -> [TimelineEvent] {
|
||||
guard let entries = try? JSONDecoder().decode([LossyEntry].self, from: data) else {
|
||||
return []
|
||||
}
|
||||
return entries.compactMap(\.value).filter(\.hasKnownClass)
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-element tolerance shim: a malformed element becomes `nil` instead of
|
||||
/// failing the whole array decode.
|
||||
private struct LossyEntry: Decodable {
|
||||
let value: TimelineEvent?
|
||||
|
||||
init(from decoder: any Decoder) {
|
||||
value = try? TimelineEvent(from: decoder)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/// Client-side tuning constants (frozen contract; SOLE value source is the
|
||||
/// plan §3.2.1 table — adding/changing a constant goes back through T-iOS-3).
|
||||
/// All named — no magic numbers anywhere downstream.
|
||||
public enum Tunables {
|
||||
/// WS keep-alive ping period. `URLSessionWebSocketTask` has NO automatic
|
||||
/// ping (plan §1) — `PingScheduler` drives an explicit `sendPing` at this
|
||||
/// interval so the terminal is never "looks connected but dead".
|
||||
public static let pingInterval: Duration = .seconds(25)
|
||||
|
||||
/// Consecutive missed pongs after which the connection is declared dead
|
||||
/// (T-iOS-5): 1 missed pong is tolerated, the 2nd is a disconnect signal.
|
||||
public static let pongMissLimit = 2
|
||||
|
||||
/// Foreground `/live-sessions` polling period. Mirrors the web launcher's
|
||||
/// refresh cadence (public/launcher.ts:30 `REFRESH_MS = 5000`).
|
||||
public static let listPollInterval: Duration = .seconds(5)
|
||||
|
||||
/// Telemetry chips grey out when `StatusTelemetry.at` is older than this.
|
||||
/// Mirrors public/tabs.ts:45 `STATUSLINE_TTL_MS` (= server default,
|
||||
/// src/config.ts:63). The server value is env-overridable at runtime while
|
||||
/// iOS bakes the default — accepted drift (plan §3.2.1).
|
||||
public static let telemetryStaleTtlMs: Int = 30_000
|
||||
|
||||
/// Away-digest banner auto-fade delay (T-iOS-14).
|
||||
public static let digestFadeDelay: Duration = .seconds(8)
|
||||
|
||||
/// Max session-title length after sanitisation (T-iOS-23; OSC titles are
|
||||
/// host/attacker-controlled input).
|
||||
public static let titleMaxLength = 256
|
||||
|
||||
/// `URLSessionWebSocketTask.maximumMessageSize`. The default (1 MiB) is too
|
||||
/// small: ring-buffer replay arrives as ONE full-snapshot frame
|
||||
/// (src/session/session.ts:165-171) and JSON escaping inflates control
|
||||
/// bytes to `\uXXXX` by 1-6x (src/protocol.ts:186), so worst case is
|
||||
/// ≈ 6 × SCROLLBACK_BYTES (default 2 MiB) plus frame envelope → 16 MiB.
|
||||
///
|
||||
/// COUPLING WARNING: SCROLLBACK_BYTES is a server env knob the client
|
||||
/// cannot discover at runtime (no config handshake). If the host raises it
|
||||
/// past ~2.7 MB (or replay is extremely escape-dense), `receive()` fails
|
||||
/// with NSPOSIXErrorDomain code 40 (ENOBUFS "Message too long") — that MUST
|
||||
/// be classified as the non-retryable `.replayTooLarge` failure and never
|
||||
/// fed into the backoff reconnect loop (plan §3.2 / T-iOS-9/10).
|
||||
public static let maxWSMessageBytes = 16 * 1024 * 1024
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
/// Boundary validation, same rules as the server (frozen contract, plan §3.1).
|
||||
/// Sources: src/protocol.ts:22-23 (SESSION_ID_RE), :113-115 (dimensions),
|
||||
/// :142-149 (cwd). Pure functions, never throw.
|
||||
public enum Validation {
|
||||
/// Byte-identical port of the server's SESSION_ID_RE
|
||||
/// (`/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i`):
|
||||
/// UUID v4 — 8-4-4-4-12 hex groups, version nibble '4', variant nibble
|
||||
/// 8/9/a/b, case-insensitive (M7). Vectors in ServerVectorTests pin the
|
||||
/// equivalence against a test-side mirror of the server regex.
|
||||
public static func isValidSessionId(_ candidate: String) -> Bool {
|
||||
let sessionIdRegex =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
.ignoresCase()
|
||||
return candidate.wholeMatch(of: sessionIdRegex) != nil
|
||||
}
|
||||
|
||||
/// Mirrors the server's `isValidDimension` range check for `resize`
|
||||
/// (integers in `WireConstants.resizeRange`). Frames outside this range
|
||||
/// are silently discarded by the server — validate before sending.
|
||||
public static func isValidResize(cols: Int, rows: Int) -> Bool {
|
||||
WireConstants.resizeRange.contains(cols) && WireConstants.resizeRange.contains(rows)
|
||||
}
|
||||
|
||||
/// Mirrors the server's `attach.cwd` check: must start with "/"
|
||||
/// (src/protocol.ts:142-149; deeper normalisation stays server-side).
|
||||
public static func isAbsoluteCwd(_ candidate: String) -> Bool {
|
||||
candidate.hasPrefix("/")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/// Wire-level constants shared with the server (frozen contract, plan §3.1).
|
||||
public enum WireConstants {
|
||||
/// WS endpoint path (src/config.ts:41 `DEFAULT_WS_PATH`).
|
||||
public static let wsPath = "/term"
|
||||
|
||||
/// Soft-reset prefix the server prepends to ring-buffer replay as a safety
|
||||
/// net (src/types.ts:167-170 / M2). Clients may use it to recognise the
|
||||
/// replay boundary; never strip it — it is valid ANSI for the terminal.
|
||||
public static let replaySoftResetPrefix = "\u{1B}[0m"
|
||||
|
||||
/// `exit.code` value meaning the PTY spawn never succeeded (M4);
|
||||
/// `exit.reason` is required server-side in that case. Not a retryable state.
|
||||
public static let spawnFailedExitCode = -1
|
||||
|
||||
/// Valid `resize` cols/rows range, inclusive (src/protocol.ts:113-115).
|
||||
public static let resizeRange: ClosedRange<Int> = 1...1000
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/// T-iOS-1 scaffold placeholder.
|
||||
///
|
||||
/// The frozen wire contract (`ClientMessage` / `ServerMessage` / `MessageCodec` /
|
||||
/// `Validation` / `WireConstants` / `Tunables` plus the shared I/O boundary types
|
||||
/// `HostEndpoint` / `TermTransport` / `HTTPTransport` / `TimelineEvent`) lands in
|
||||
/// T-iOS-3 and is owned exclusively by that task — do not add contract types here.
|
||||
public enum WireProtocolPackage {
|
||||
/// Package identity marker consumed by the scaffold smoke tests (downstream
|
||||
/// packages reference it to prove their dependency edge compiles).
|
||||
public static let packageName = "WireProtocol"
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import WireProtocol
|
||||
|
||||
// T-iOS-3 · MessageCodec.encode 与服务器 parseClientMessage 的逐键契约 + roundtrip
|
||||
// property + fuzz。向量出处:src/protocol.ts、src/server.ts:91-102、test/protocol.test.ts。
|
||||
|
||||
// MARK: - encode 形状(服务器视角逐键一致)
|
||||
|
||||
@Test("encode(attach) 无 sessionId 时也显式携带 sessionId:null(src/protocol.ts:132-134)")
|
||||
func encodeAttachCarriesExplicitNullSessionId() {
|
||||
// Arrange / Act
|
||||
let frame = MessageCodec.encode(.attach(sessionId: nil, cwd: nil))
|
||||
|
||||
// Assert — 逐字节 + 服务器视角双重断言
|
||||
#expect(frame == "{\"type\":\"attach\",\"sessionId\":null}")
|
||||
#expect(ServerViewParser.parse(frame) == .attach(sessionId: nil, cwd: nil))
|
||||
}
|
||||
|
||||
@Test("encode(attach) 带 UUID → 服务器收到小写 UUID 字符串")
|
||||
func encodeAttachSerializesLowercasedUUID() {
|
||||
// Arrange
|
||||
let uuid = UUID(uuidString: "F47AC10B-58CC-4372-A567-0E02B2C3D479")!
|
||||
|
||||
// Act
|
||||
let frame = MessageCodec.encode(.attach(sessionId: uuid, cwd: nil))
|
||||
|
||||
// Assert
|
||||
#expect(frame == "{\"type\":\"attach\",\"sessionId\":\"f47ac10b-58cc-4372-a567-0e02b2c3d479\"}")
|
||||
#expect(ServerViewParser.parse(frame)
|
||||
== .attach(sessionId: "f47ac10b-58cc-4372-a567-0e02b2c3d479", cwd: nil))
|
||||
}
|
||||
|
||||
@Test("encode(attach) 带 cwd → cwd 键出现且为绝对路径原文;无 cwd 时键不出现")
|
||||
func encodeAttachCwdKeyAppearsOnlyWhenPresent() throws {
|
||||
// Arrange / Act
|
||||
let withCwd = MessageCodec.encode(.attach(sessionId: nil, cwd: "/Users/dev/proj"))
|
||||
let withoutCwd = MessageCodec.encode(.attach(sessionId: nil, cwd: nil))
|
||||
|
||||
// Assert
|
||||
#expect(ServerViewParser.parse(withCwd) == .attach(sessionId: nil, cwd: "/Users/dev/proj"))
|
||||
let withoutObj = try #require(jsonObject(withoutCwd))
|
||||
#expect(withoutObj.index(forKey: "cwd") == nil)
|
||||
}
|
||||
|
||||
@Test("encode(input) 原始键盘字节逐字节透传(Esc/^C/CR/Tab/Shift+Tab)")
|
||||
func encodeInputPassesRawKeyboardBytesVerbatim() {
|
||||
// Arrange — test/protocol.test.ts:79 的 verbatim 向量
|
||||
let raw = "\u{1B}[A\u{03}\r\t\u{1B}[Z"
|
||||
|
||||
// Act
|
||||
let frame = MessageCodec.encode(.input(data: raw))
|
||||
|
||||
// Assert
|
||||
#expect(ServerViewParser.parse(frame) == .input(data: raw))
|
||||
}
|
||||
|
||||
@Test("encode(input) 控制字节按 JSON.stringify 规则转义(\\r 短转义、ESC → \\u001b)")
|
||||
func encodeInputEscapesControlBytesLikeJSONStringify() {
|
||||
// Arrange / Act
|
||||
let frame = MessageCodec.encode(.input(data: "hi\r\u{1B}[A\"\\"))
|
||||
|
||||
// Assert — 与 JS 客户端 JSON.stringify 输出逐字节一致
|
||||
#expect(frame == "{\"type\":\"input\",\"data\":\"hi\\r\\u001b[A\\\"\\\\\"}")
|
||||
}
|
||||
|
||||
@Test("encode(resize) cols/rows 为 JSON 整数,服务器按 [1,1000] 接受")
|
||||
func encodeResizeEmitsIntegers() {
|
||||
// Arrange / Act
|
||||
let frame = MessageCodec.encode(.resize(cols: 120, rows: 40))
|
||||
|
||||
// Assert
|
||||
#expect(frame == "{\"type\":\"resize\",\"cols\":120,\"rows\":40}")
|
||||
#expect(ServerViewParser.parse(frame) == .resize(cols: 120, rows: 40))
|
||||
#expect(ServerViewParser.parse(MessageCodec.encode(.resize(cols: 1, rows: 1)))
|
||||
== .resize(cols: 1, rows: 1))
|
||||
#expect(ServerViewParser.parse(MessageCodec.encode(.resize(cols: 1000, rows: 1000)))
|
||||
== .resize(cols: 1000, rows: 1000))
|
||||
}
|
||||
|
||||
@Test("encode(approve) 无 mode → 裸 {type:approve};encode(reject) → {type:reject}")
|
||||
func encodeApproveRejectBareFrames() {
|
||||
// Arrange / Act / Assert
|
||||
#expect(MessageCodec.encode(.approve(mode: nil)) == "{\"type\":\"approve\"}")
|
||||
#expect(MessageCodec.encode(.reject) == "{\"type\":\"reject\"}")
|
||||
#expect(ServerViewParser.parse("{\"type\":\"approve\"}") == .approve)
|
||||
#expect(ServerViewParser.parse("{\"type\":\"reject\"}") == .reject)
|
||||
}
|
||||
|
||||
@Test("encode(approve.mode) 把 mode 放在顶层键(src/server.ts:94-102 读原始帧 obj['mode'])",
|
||||
arguments: ApproveMode.allCases)
|
||||
func encodeApproveModeIsTopLevelKey(mode: ApproveMode) throws {
|
||||
// Arrange / Act
|
||||
let frame = MessageCodec.encode(.approve(mode: mode))
|
||||
|
||||
// Assert — 服务器 parseClientMessage 接受该帧形状
|
||||
#expect(ServerViewParser.parse(frame) == .approve)
|
||||
// …且 WS 接线层的 parseApproveMode 能从顶层恢复 mode
|
||||
#expect(ServerViewParser.topLevelMode(frame) == mode.rawValue)
|
||||
let obj = try #require(jsonObject(frame))
|
||||
#expect(obj["mode"] as? String == mode.rawValue)
|
||||
}
|
||||
|
||||
@Test("ApproveMode rawValue 与服务器 PERMISSION_MODES 白名单一致(src/server.ts:76)")
|
||||
func approveModeRawValuesMatchServerWhitelist() {
|
||||
// Arrange / Act
|
||||
let rawValues = Set(ApproveMode.allCases.map(\.rawValue))
|
||||
|
||||
// Assert
|
||||
#expect(rawValues == ServerViewParser.permissionModes)
|
||||
}
|
||||
|
||||
// MARK: - roundtrip property(approve.mode 豁免:服务器刻意丢 mode,src/protocol.ts:77-79)
|
||||
|
||||
@Test("roundtrip property:任意合法 ClientMessage encode→(服务器视角)decode 不变形")
|
||||
func roundtripPropertyHoldsForRandomClientMessages() {
|
||||
// Arrange — 固定种子保证可复现
|
||||
var rng = SplitMix64(seed: 0xC0FF_EE00_0003)
|
||||
let iterations = 300
|
||||
|
||||
for _ in 0..<iterations {
|
||||
let original = MessageGen.randomClientMessage(using: &rng)
|
||||
|
||||
// Act
|
||||
let frame = MessageCodec.encode(original)
|
||||
let parsed = ServerViewParser.parse(frame)
|
||||
|
||||
// Assert
|
||||
assertServerViewMatches(original: original, frame: frame, parsed: parsed)
|
||||
}
|
||||
}
|
||||
|
||||
private func assertServerViewMatches(
|
||||
original: ClientMessage, frame: String, parsed: ServerViewMessage?
|
||||
) {
|
||||
switch (original, parsed) {
|
||||
case let (.attach(sessionId, cwd), .attach(parsedSessionId, parsedCwd)):
|
||||
#expect(parsedSessionId == sessionId.map { $0.uuidString.lowercased() })
|
||||
#expect(parsedCwd == cwd)
|
||||
case let (.input(data), .input(parsedData)):
|
||||
#expect(parsedData == data)
|
||||
case let (.resize(cols, rows), .resize(parsedCols, parsedRows)):
|
||||
#expect(parsedCols == cols)
|
||||
#expect(parsedRows == rows)
|
||||
case let (.approve(mode), .approve):
|
||||
// approve 豁免:只断言形状被接受 + 顶层 mode 可恢复
|
||||
#expect(ServerViewParser.topLevelMode(frame) == mode?.rawValue)
|
||||
case (.reject, .reject):
|
||||
break
|
||||
default:
|
||||
Issue.record("服务器视角解析结果与原消息 case 不符: \(original) → \(String(describing: parsed)), frame=\(frame)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - fuzz:decodeServer 对随机字节永不 crash(安全注:服务器是不可信输入源)
|
||||
|
||||
@Test("fuzz:300 轮随机字节喂 decodeServer 不 crash(非法 → nil)")
|
||||
func decodeServerSurvivesRandomByteFuzz() {
|
||||
// Arrange
|
||||
var rng = SplitMix64(seed: 0xDEAD_BEEF_0003)
|
||||
|
||||
for _ in 0..<300 {
|
||||
let length = Int.random(in: 0...80, using: &rng)
|
||||
let bytes = (0..<length).map { _ in UInt8.random(in: 0...255, using: &rng) }
|
||||
let text = String(decoding: bytes, as: UTF8.self)
|
||||
|
||||
// Act — 不得 throw / crash;返回值任意
|
||||
_ = MessageCodec.decodeServer(text)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("fuzz:合法帧随机单字节突变喂 decodeServer 不 crash")
|
||||
func decodeServerSurvivesMutatedValidFrames() {
|
||||
// Arrange
|
||||
var rng = SplitMix64(seed: 0xFEED_FACE_0003)
|
||||
let seeds = [
|
||||
"{\"type\":\"attached\",\"sessionId\":\"f47ac10b-58cc-4372-a567-0e02b2c3d479\"}",
|
||||
"{\"type\":\"output\",\"data\":\"\\u001b[1;32mHello\\u001b[0m\"}",
|
||||
"{\"type\":\"exit\",\"code\":-1,\"reason\":\"spawn failed\"}",
|
||||
"{\"type\":\"status\",\"status\":\"waiting\",\"pending\":true,\"gate\":\"plan\"}",
|
||||
"{\"type\":\"telemetry\",\"telemetry\":{\"at\":1751600000000,\"costUsd\":1.5}}",
|
||||
]
|
||||
|
||||
for seed in seeds {
|
||||
for _ in 0..<40 {
|
||||
var bytes = Array(seed.utf8)
|
||||
let index = Int.random(in: 0..<bytes.count, using: &rng)
|
||||
bytes[index] = UInt8.random(in: 0...255, using: &rng)
|
||||
|
||||
// Act — 不得 crash
|
||||
_ = MessageCodec.decodeServer(String(decoding: bytes, as: UTF8.self))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("客户端帧类型(attach/input/resize/approve/reject)不是服务器帧 → decodeServer nil")
|
||||
func decodeServerRejectsClientFrameTypes() {
|
||||
// Arrange
|
||||
let clientFrames: [ClientMessage] = [
|
||||
.attach(sessionId: nil, cwd: nil), .input(data: "x"),
|
||||
.resize(cols: 80, rows: 24), .approve(mode: .plan), .reject,
|
||||
]
|
||||
|
||||
for message in clientFrames {
|
||||
// Act / Assert
|
||||
#expect(MessageCodec.decodeServer(MessageCodec.encode(message)) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - helpers
|
||||
|
||||
private func jsonObject(_ text: String) -> [String: Any]? {
|
||||
guard let data = text.data(using: .utf8) else { return nil }
|
||||
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import WireProtocol
|
||||
|
||||
// T-iOS-3 · 冻结契约常量绊线:Tunables(§3.2.1 唯一取值表)与 WireConstants。
|
||||
// 这些断言故意逐字重复取值表——改常量必须回 T-iOS-3 改契约并同步这里。
|
||||
|
||||
@Test("Tunables 取值与 §3.2.1 表逐项一致")
|
||||
func tunablesMatchFrozenValueTable() {
|
||||
#expect(Tunables.pingInterval == .seconds(25))
|
||||
#expect(Tunables.pongMissLimit == 2)
|
||||
#expect(Tunables.listPollInterval == .seconds(5)) // public/launcher.ts:30 REFRESH_MS
|
||||
#expect(Tunables.telemetryStaleTtlMs == 30_000) // public/tabs.ts:45 STATUSLINE_TTL_MS
|
||||
#expect(Tunables.digestFadeDelay == .seconds(8))
|
||||
#expect(Tunables.titleMaxLength == 256)
|
||||
#expect(Tunables.maxWSMessageBytes == 16 * 1024 * 1024)
|
||||
}
|
||||
|
||||
@Test("maxWSMessageBytes ≥ 6 × 默认 SCROLLBACK_BYTES(2MiB)(JSON 转义最坏膨胀系数)")
|
||||
func maxWSMessageBytesCoversWorstCaseReplayExpansion() {
|
||||
// Arrange — src/config.ts:39 DEFAULT_SCROLLBACK_BYTES = 2MiB;\uXXXX 转义最坏 6×
|
||||
let defaultScrollbackBytes = 2 * 1024 * 1024
|
||||
let worstCaseEscapeFactor = 6
|
||||
|
||||
// Assert
|
||||
#expect(Tunables.maxWSMessageBytes >= worstCaseEscapeFactor * defaultScrollbackBytes)
|
||||
}
|
||||
|
||||
@Test("WireConstants:wsPath/soft-reset 前缀/spawn 失败码/resize 区间")
|
||||
func wireConstantsMatchServer() {
|
||||
#expect(WireConstants.wsPath == "/term") // src/config.ts:41 DEFAULT_WS_PATH
|
||||
#expect(WireConstants.replaySoftResetPrefix == "\u{1B}[0m") // src/types.ts:167-170
|
||||
#expect(WireConstants.spawnFailedExitCode == -1) // M4
|
||||
#expect(WireConstants.resizeRange == 1...1000) // src/protocol.ts:113-115
|
||||
}
|
||||
|
||||
@Test("TransportConnection:能力句柄原样保存;frames finish = 干净断线")
|
||||
func transportConnectionHoldsCapabilityHandles() async throws {
|
||||
// Arrange
|
||||
actor Recorder {
|
||||
var sentFrames: [String] = []
|
||||
var isClosed = false
|
||||
func recordSend(_ frame: String) { sentFrames.append(frame) }
|
||||
func recordClose() { isClosed = true }
|
||||
}
|
||||
let recorder = Recorder()
|
||||
let connection = TransportConnection(
|
||||
frames: AsyncThrowingStream { continuation in
|
||||
continuation.yield("{\"type\":\"output\",\"data\":\"x\"}")
|
||||
continuation.finish()
|
||||
},
|
||||
send: { await recorder.recordSend($0) },
|
||||
close: { await recorder.recordClose() }
|
||||
)
|
||||
|
||||
// Act
|
||||
var received: [String] = []
|
||||
for try await frame in connection.frames {
|
||||
received.append(frame)
|
||||
}
|
||||
try await connection.send("{\"type\":\"reject\"}")
|
||||
await connection.close()
|
||||
|
||||
// Assert
|
||||
#expect(received == ["{\"type\":\"output\",\"data\":\"x\"}"])
|
||||
#expect(await recorder.sentFrames == ["{\"type\":\"reject\"}"])
|
||||
#expect(await recorder.isClosed)
|
||||
}
|
||||
|
||||
@Test("ClaudeStatus/GateKind rawValue 与服务器字面量一致")
|
||||
func statusAndGateRawValuesMatchServer() {
|
||||
#expect(ClaudeStatus.working.rawValue == "working")
|
||||
#expect(ClaudeStatus.waiting.rawValue == "waiting")
|
||||
#expect(ClaudeStatus.idle.rawValue == "idle")
|
||||
#expect(ClaudeStatus.unknown.rawValue == "unknown")
|
||||
#expect(ClaudeStatus.stuck.rawValue == "stuck")
|
||||
#expect(GateKind.tool.rawValue == "tool")
|
||||
#expect(GateKind.plan.rawValue == "plan")
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import WireProtocol
|
||||
|
||||
// T-iOS-3 · HostEndpoint originHeader/wsURL 派生向量(含原 T-iOS-7 用例,plan §7)。
|
||||
// Origin 铁律:单点派生,禁止手拼(plan §5.1);默认端口省略与浏览器 Origin 序列化一致。
|
||||
|
||||
private func endpoint(_ urlString: String) -> HostEndpoint? {
|
||||
guard let url = URL(string: urlString) else { return nil }
|
||||
return HostEndpoint(baseURL: url)
|
||||
}
|
||||
|
||||
// MARK: - originHeader 派生向量
|
||||
|
||||
@Test("originHeader:http + IPv4 + 非默认端口 → 与 baseURL 同串")
|
||||
func originHeaderKeepsNonDefaultPortIPv4() throws {
|
||||
let sut = try #require(endpoint("http://192.168.1.5:3000"))
|
||||
#expect(sut.originHeader == "http://192.168.1.5:3000")
|
||||
}
|
||||
|
||||
@Test("originHeader:https + 非标端口保留端口")
|
||||
func originHeaderKeepsNonStandardHTTPSPort() throws {
|
||||
let sut = try #require(endpoint("https://mac.example:8443"))
|
||||
#expect(sut.originHeader == "https://mac.example:8443")
|
||||
}
|
||||
|
||||
@Test("originHeader:https + 443 → 无端口后缀(浏览器 Origin 序列化)")
|
||||
func originHeaderOmitsDefaultHTTPSPort() throws {
|
||||
let sut = try #require(endpoint("https://mac.example:443"))
|
||||
#expect(sut.originHeader == "https://mac.example")
|
||||
}
|
||||
|
||||
@Test("originHeader:http + 80 → 无端口后缀")
|
||||
func originHeaderOmitsDefaultHTTPPort() throws {
|
||||
let sut = try #require(endpoint("http://mac.example:80"))
|
||||
#expect(sut.originHeader == "http://mac.example")
|
||||
}
|
||||
|
||||
@Test("originHeader:无端口 URL → 无端口后缀;localhost 保留非默认端口")
|
||||
func originHeaderWithoutExplicitPort() throws {
|
||||
let bare = try #require(endpoint("https://mac.tailnet-1234.ts.net"))
|
||||
#expect(bare.originHeader == "https://mac.tailnet-1234.ts.net")
|
||||
let localhost = try #require(endpoint("http://localhost:3000"))
|
||||
#expect(localhost.originHeader == "http://localhost:3000")
|
||||
}
|
||||
|
||||
@Test("originHeader:scheme 与 host 小写规范化(服务器两侧 new URL() 规范化对称)")
|
||||
func originHeaderLowercasesSchemeAndHost() throws {
|
||||
let sut = try #require(endpoint("HTTP://Mac.Example:3000"))
|
||||
#expect(sut.originHeader == "http://mac.example:3000")
|
||||
}
|
||||
|
||||
@Test("originHeader:baseURL 的 path/query 不进入 Origin")
|
||||
func originHeaderIgnoresPathAndQuery() throws {
|
||||
let sut = try #require(endpoint("http://192.168.1.5:3000/index.html?x=1"))
|
||||
#expect(sut.originHeader == "http://192.168.1.5:3000")
|
||||
}
|
||||
|
||||
@Test("originHeader:IPv6 host 保留方括号")
|
||||
func originHeaderBracketsIPv6Host() throws {
|
||||
let sut = try #require(endpoint("http://[fe80::1]:3000"))
|
||||
#expect(sut.originHeader == "http://[fe80::1]:3000")
|
||||
}
|
||||
|
||||
// MARK: - wsURL 派生向量(scheme http→ws / https→wss + WireConstants.wsPath)
|
||||
|
||||
@Test("wsURL:http → ws 同 host 同 port + /term")
|
||||
func wsURLDerivesWsFromHttp() throws {
|
||||
let sut = try #require(endpoint("http://192.168.1.5:3000"))
|
||||
#expect(sut.wsURL.absoluteString == "ws://192.168.1.5:3000/term")
|
||||
}
|
||||
|
||||
@Test("wsURL:https → wss + /term(非标端口保留)")
|
||||
func wsURLDerivesWssFromHttps() throws {
|
||||
let sut = try #require(endpoint("https://mac.example:8443"))
|
||||
#expect(sut.wsURL.absoluteString == "wss://mac.example:8443/term")
|
||||
}
|
||||
|
||||
@Test("wsURL:无端口 https → wss 无端口;path 恒为 WireConstants.wsPath")
|
||||
func wsURLWithoutPort() throws {
|
||||
let sut = try #require(endpoint("https://mac.tailnet-1234.ts.net"))
|
||||
#expect(sut.wsURL.absoluteString == "wss://mac.tailnet-1234.ts.net/term")
|
||||
#expect(sut.wsURL.path == WireConstants.wsPath)
|
||||
}
|
||||
|
||||
@Test("wsURL:baseURL 带尾斜杠/path/query 时仍只保留 /term")
|
||||
func wsURLReplacesPathAndDropsQuery() throws {
|
||||
let sut = try #require(endpoint("http://192.168.1.5:3000/launcher?join=abc"))
|
||||
#expect(sut.wsURL.absoluteString == "ws://192.168.1.5:3000/term")
|
||||
}
|
||||
|
||||
// MARK: - 输入边界:非 http(s) / 无 host 拒绝(扫码结果是不可信输入,plan §5)
|
||||
|
||||
@Test("init:非 http(s) scheme 或无 host → nil",
|
||||
arguments: ["ftp://mac.example", "file:///tmp/x", "mailto:hi@example.com", "http://", "ws://mac.example:3000"])
|
||||
func initRejectsNonHTTPBaseURLs(urlString: String) {
|
||||
#expect(endpoint(urlString) == nil)
|
||||
}
|
||||
|
||||
// MARK: - Codable(HostRegistry Keychain 持久化经由它)
|
||||
|
||||
@Test("Codable roundtrip:encode→decode 恒等")
|
||||
func codableRoundtripPreservesEquality() throws {
|
||||
// Arrange
|
||||
let original = try #require(endpoint("https://mac.example:8443"))
|
||||
|
||||
// Act
|
||||
let data = try JSONEncoder().encode(original)
|
||||
let decoded = try JSONDecoder().decode(HostEndpoint.self, from: data)
|
||||
|
||||
// Assert
|
||||
#expect(decoded == original)
|
||||
#expect(decoded.originHeader == original.originHeader)
|
||||
#expect(decoded.wsURL == original.wsURL)
|
||||
}
|
||||
|
||||
@Test("Codable:持久化数据被篡改成非 http(s) URL → decode 显式 throw(边界再验证)")
|
||||
func codableDecodeRevalidatesBaseURL() {
|
||||
// Arrange
|
||||
let tampered = Data("{\"baseURL\":\"ftp://mac.example\"}".utf8)
|
||||
|
||||
// Act / Assert
|
||||
#expect(throws: DecodingError.self) {
|
||||
_ = try JSONDecoder().decode(HostEndpoint.self, from: tampered)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Testing
|
||||
import WireProtocol
|
||||
|
||||
@Test("脚手架:WireProtocol 包可编译、可导入")
|
||||
func wireProtocolPackageIsImportable() {
|
||||
// Arrange / Act
|
||||
let name = WireProtocolPackage.packageName
|
||||
|
||||
// Assert
|
||||
#expect(name == "WireProtocol")
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import WireProtocol
|
||||
|
||||
// T-iOS-3 · 从 test/protocol.test.ts 移植的跨实现向量 + decodeServer 白名单解码。
|
||||
// 双实现(TS/Swift)防漂移:这里锁当前语义,真服务器回归由 T-iOS-16 CI 常驻看护。
|
||||
|
||||
private let validUUID = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
|
||||
|
||||
// MARK: - Validation.isValidSessionId(SESSION_ID_RE 向量, test/protocol.test.ts:19-49)
|
||||
|
||||
@Test("isValidSessionId:合法 UUID v4 通过")
|
||||
func sessionIdAcceptsValidUUIDv4() {
|
||||
#expect(Validation.isValidSessionId(validUUID))
|
||||
}
|
||||
|
||||
@Test("isValidSessionId:大写十六进制也通过(服务器正则 /i)")
|
||||
func sessionIdAcceptsUppercaseHex() {
|
||||
#expect(Validation.isValidSessionId("F47AC10B-58CC-4372-A567-0E02B2C3D479"))
|
||||
}
|
||||
|
||||
@Test("isValidSessionId:variant=8 的 UUID v4 通过")
|
||||
func sessionIdAcceptsVariant8() {
|
||||
#expect(Validation.isValidSessionId("550e8400-e29b-41d4-8716-446655440000"))
|
||||
}
|
||||
|
||||
@Test("isValidSessionId:非 UUID / 空串 / v1 / 错误 variant 全部拒绝(M7)",
|
||||
arguments: [
|
||||
"abc123",
|
||||
"",
|
||||
"550e8400-e29b-11d4-a716-446655440000", // v1:版本位=1
|
||||
"f47ac10b-58cc-4372-c567-0e02b2c3d479", // variant 'c'(非 8/9/a/b)
|
||||
"f47ac10b58cc4372a5670e02b2c3d479", // 缺分隔符
|
||||
"f47ac10b-58cc-4372-a567-0e02b2c3d47", // 少一位
|
||||
"g47ac10b-58cc-4372-a567-0e02b2c3d479", // 非法字符
|
||||
])
|
||||
func sessionIdRejectsInvalid(candidate: String) {
|
||||
#expect(!Validation.isValidSessionId(candidate))
|
||||
}
|
||||
|
||||
@Test("isValidSessionId 与测试侧服务器正则镜像逐向量同判(防移植走样)")
|
||||
func sessionIdAgreesWithServerRegexMirror() {
|
||||
// Arrange — 正反两面向量各若干
|
||||
let candidates = [
|
||||
validUUID, "F47AC10B-58CC-4372-A567-0E02B2C3D479",
|
||||
"550e8400-e29b-41d4-8716-446655440000", "abc123", "",
|
||||
"550e8400-e29b-11d4-a716-446655440000",
|
||||
"f47ac10b-58cc-4372-c567-0e02b2c3d479",
|
||||
]
|
||||
|
||||
for candidate in candidates {
|
||||
// Act / Assert
|
||||
#expect(Validation.isValidSessionId(candidate)
|
||||
== ServerViewParser.isServerValidSessionId(candidate),
|
||||
"分歧向量: \(candidate)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - isValidResize / isAbsoluteCwd(src/protocol.ts:113-115,142-149)
|
||||
|
||||
@Test("isValidResize:边界 1/1000 通过,0/1001 拒绝")
|
||||
func resizeValidationBoundaries() {
|
||||
#expect(Validation.isValidResize(cols: 1, rows: 1))
|
||||
#expect(Validation.isValidResize(cols: 1000, rows: 1000))
|
||||
#expect(Validation.isValidResize(cols: 120, rows: 40))
|
||||
#expect(!Validation.isValidResize(cols: 0, rows: 40))
|
||||
#expect(!Validation.isValidResize(cols: 1001, rows: 40))
|
||||
#expect(!Validation.isValidResize(cols: 80, rows: 0))
|
||||
#expect(!Validation.isValidResize(cols: 80, rows: 1001))
|
||||
}
|
||||
|
||||
@Test("isAbsoluteCwd:'/a' 通过;'a' 与空串拒绝")
|
||||
func absoluteCwdValidation() {
|
||||
#expect(Validation.isAbsoluteCwd("/a"))
|
||||
#expect(!Validation.isAbsoluteCwd("a"))
|
||||
#expect(!Validation.isAbsoluteCwd(""))
|
||||
}
|
||||
|
||||
// MARK: - 服务器 parseClientMessage 向量 ↔ 测试侧镜像(transcribed from test/protocol.test.ts)
|
||||
|
||||
@Test("服务器视角镜像:合法客户端帧向量全部接受(test/protocol.test.ts:53-124)")
|
||||
func serverMirrorAcceptsValidClientVectors() {
|
||||
#expect(ServerViewParser.parse("{\"type\":\"attach\",\"sessionId\":null}")
|
||||
== .attach(sessionId: nil, cwd: nil))
|
||||
#expect(ServerViewParser.parse("{\"type\":\"attach\",\"sessionId\":\"\(validUUID)\"}")
|
||||
== .attach(sessionId: validUUID, cwd: nil))
|
||||
#expect(ServerViewParser.parse("{\"type\":\"input\",\"data\":\"ls -la\\r\"}")
|
||||
== .input(data: "ls -la\r"))
|
||||
#expect(ServerViewParser.parse("{\"type\":\"resize\",\"cols\":1,\"rows\":1}")
|
||||
== .resize(cols: 1, rows: 1))
|
||||
#expect(ServerViewParser.parse("{\"type\":\"resize\",\"cols\":1000,\"rows\":1000}")
|
||||
== .resize(cols: 1000, rows: 1000))
|
||||
#expect(ServerViewParser.parse("{\"type\":\"approve\"}") == .approve)
|
||||
#expect(ServerViewParser.parse("{\"type\":\"reject\"}") == .reject)
|
||||
// 已移除的 blur 类型必须被拒(test/protocol.test.ts:120-123)
|
||||
#expect(ServerViewParser.parse("{\"type\":\"blur\"}") == nil)
|
||||
// 多余字段忽略(test/protocol.test.ts:284)
|
||||
#expect(ServerViewParser.parse("{\"type\":\"resize\",\"cols\":80,\"rows\":40,\"extra\":\"ignored\"}")
|
||||
== .resize(cols: 80, rows: 40))
|
||||
}
|
||||
|
||||
@Test("服务器视角镜像:非法客户端帧向量全部拒绝(test/protocol.test.ts:128-266)",
|
||||
arguments: [
|
||||
"not json at all", "", "null", "[]", "42",
|
||||
"{\"type\":\"ping\"}", "{\"data\":\"hello\"}", "{\"type\":42}",
|
||||
"{\"type\":\"resize\",\"cols\":0,\"rows\":40}",
|
||||
"{\"type\":\"resize\",\"cols\":1001,\"rows\":40}",
|
||||
"{\"type\":\"resize\",\"cols\":80,\"rows\":0}",
|
||||
"{\"type\":\"resize\",\"cols\":80,\"rows\":1001}",
|
||||
"{\"type\":\"resize\",\"cols\":80.5,\"rows\":40}",
|
||||
"{\"type\":\"resize\",\"cols\":80,\"rows\":24.9}",
|
||||
"{\"type\":\"resize\",\"cols\":\"80\",\"rows\":40}",
|
||||
"{\"type\":\"resize\",\"rows\":40}",
|
||||
"{\"type\":\"resize\",\"cols\":80}",
|
||||
"{\"type\":\"input\",\"data\":42}",
|
||||
"{\"type\":\"input\",\"data\":null}",
|
||||
"{\"type\":\"input\"}",
|
||||
"{\"type\":\"input\",\"data\":{}}",
|
||||
"{\"type\":\"attach\",\"sessionId\":\"abc123\"}",
|
||||
"{\"type\":\"attach\",\"sessionId\":\"\"}",
|
||||
"{\"type\":\"attach\",\"sessionId\":42}",
|
||||
"{\"type\":\"attach\"}",
|
||||
"{\"type\":\"attach\",\"sessionId\":null,\"cwd\":\"relative\"}",
|
||||
"{\"type\":\"attach\",\"sessionId\":null,\"cwd\":42}",
|
||||
])
|
||||
func serverMirrorRejectsInvalidClientVectors(frame: String) {
|
||||
#expect(ServerViewParser.parse(frame) == nil)
|
||||
}
|
||||
|
||||
// MARK: - decodeServer 合法帧(5 种 case)
|
||||
|
||||
@Test("decodeServer(attached):合法 UUID → .attached;大写 UUID 同样接受")
|
||||
func decodeAttachedFrames() {
|
||||
// Arrange
|
||||
let expected = UUID(uuidString: validUUID)!
|
||||
|
||||
// Act / Assert
|
||||
#expect(MessageCodec.decodeServer("{\"type\":\"attached\",\"sessionId\":\"\(validUUID)\"}")
|
||||
== .attached(sessionId: expected))
|
||||
#expect(MessageCodec.decodeServer(
|
||||
"{\"type\":\"attached\",\"sessionId\":\"F47AC10B-58CC-4372-A567-0E02B2C3D479\"}")
|
||||
== .attached(sessionId: expected))
|
||||
}
|
||||
|
||||
@Test("decodeServer(output):ANSI 字节原样进 data(serialize 向量, test/protocol.test.ts:303-307)")
|
||||
func decodeOutputFrame() {
|
||||
#expect(MessageCodec.decodeServer("{\"type\":\"output\",\"data\":\"\\u001b[1;32mHello\\u001b[0m\"}")
|
||||
== .output(data: "\u{1B}[1;32mHello\u{1B}[0m"))
|
||||
}
|
||||
|
||||
@Test("decodeServer(exit):仅 code / code+reason 两形状(-1 = spawn 失败)")
|
||||
func decodeExitFrames() {
|
||||
#expect(MessageCodec.decodeServer("{\"type\":\"exit\",\"code\":0}")
|
||||
== .exit(code: 0, reason: nil))
|
||||
#expect(MessageCodec.decodeServer(
|
||||
"{\"type\":\"exit\",\"code\":-1,\"reason\":\"spawn failed: /bin/badshell\"}")
|
||||
== .exit(code: WireConstants.spawnFailedExitCode, reason: "spawn failed: /bin/badshell"))
|
||||
}
|
||||
|
||||
@Test("decodeServer(status):5 个 ClaudeStatus 值全解;缺省 detail/pending/gate 取安全默认",
|
||||
arguments: ["working", "waiting", "idle", "unknown", "stuck"])
|
||||
func decodeStatusAllClaudeStatuses(raw: String) throws {
|
||||
// Arrange / Act
|
||||
let message = MessageCodec.decodeServer("{\"type\":\"status\",\"status\":\"\(raw)\"}")
|
||||
|
||||
// Assert
|
||||
let expectedStatus = try #require(ClaudeStatus(rawValue: raw))
|
||||
#expect(message == .status(expectedStatus, detail: nil, pending: false, gate: nil))
|
||||
}
|
||||
|
||||
@Test("decodeServer(status):pending/gate/detail 全携带(tool 与 plan 两种 gate)")
|
||||
func decodeStatusWithGate() {
|
||||
#expect(MessageCodec.decodeServer(
|
||||
"{\"type\":\"status\",\"status\":\"waiting\",\"detail\":\"Bash\",\"pending\":true,\"gate\":\"tool\"}")
|
||||
== .status(.waiting, detail: "Bash", pending: true, gate: .tool))
|
||||
#expect(MessageCodec.decodeServer(
|
||||
"{\"type\":\"status\",\"status\":\"waiting\",\"pending\":true,\"gate\":\"plan\"}")
|
||||
== .status(.waiting, detail: nil, pending: true, gate: .plan))
|
||||
}
|
||||
|
||||
@Test("decodeServer(status):未知 gate 值按缺席容忍(保留 pending 信号,不丢帧)")
|
||||
func decodeStatusToleratesUnknownGate() {
|
||||
#expect(MessageCodec.decodeServer(
|
||||
"{\"type\":\"status\",\"status\":\"waiting\",\"pending\":true,\"gate\":\"alien\"}")
|
||||
== .status(.waiting, detail: nil, pending: true, gate: nil))
|
||||
// pending 非 bool → 安全默认 false
|
||||
#expect(MessageCodec.decodeServer(
|
||||
"{\"type\":\"status\",\"status\":\"working\",\"pending\":\"yes\"}")
|
||||
== .status(.working, detail: nil, pending: false, gate: nil))
|
||||
}
|
||||
|
||||
@Test("decodeServer(telemetry):全字段样本逐字段解出(src/types.ts:406-416 镜像)")
|
||||
func decodeTelemetryFullSample() throws {
|
||||
// Arrange
|
||||
let frame = """
|
||||
{"type":"telemetry","telemetry":{"contextUsedPct":42.5,"costUsd":1.23,\
|
||||
"linesAdded":10,"linesRemoved":2,"model":"Fable 5","effort":"high",\
|
||||
"pr":{"number":7,"url":"https://github.com/x/y/pull/7","reviewState":"APPROVED"},\
|
||||
"rate":{"fiveHourPct":12.5,"sevenDayPct":33},"at":1751600000000}}
|
||||
"""
|
||||
|
||||
// Act
|
||||
let message = try #require(MessageCodec.decodeServer(frame))
|
||||
|
||||
// Assert
|
||||
let expected = StatusTelemetry(
|
||||
contextUsedPct: 42.5, costUsd: 1.23, linesAdded: 10, linesRemoved: 2,
|
||||
model: "Fable 5", effort: "high",
|
||||
pr: PrInfo(number: 7, url: "https://github.com/x/y/pull/7", reviewState: "APPROVED"),
|
||||
rate: RateInfo(fiveHourPct: 12.5, sevenDayPct: 33),
|
||||
at: 1_751_600_000_000
|
||||
)
|
||||
#expect(message == .telemetry(expected))
|
||||
}
|
||||
|
||||
@Test("decodeServer(telemetry):全可选字段缺省仍可解(只有 at)")
|
||||
func decodeTelemetryMinimalSample() {
|
||||
#expect(MessageCodec.decodeServer("{\"type\":\"telemetry\",\"telemetry\":{\"at\":123}}")
|
||||
== .telemetry(StatusTelemetry(at: 123)))
|
||||
}
|
||||
|
||||
@Test("decodeServer(telemetry):可选字段类型错误按缺席容忍(帧保留)")
|
||||
func decodeTelemetryToleratesWrongTypedOptionalField() {
|
||||
#expect(MessageCodec.decodeServer(
|
||||
"{\"type\":\"telemetry\",\"telemetry\":{\"at\":5,\"costUsd\":\"lots\",\"pr\":42}}")
|
||||
== .telemetry(StatusTelemetry(at: 5)))
|
||||
}
|
||||
|
||||
// MARK: - decodeServer 非法帧全表 → nil(永不 throw)
|
||||
|
||||
@Test("decodeServer:非法帧全表 → nil(坏 JSON/未知 type/字段缺失或错型/at 缺失)",
|
||||
arguments: [
|
||||
"not json at all", "", "null", "[]", "42", "true",
|
||||
"{\"type\":\"ping\"}", "{\"data\":\"hello\"}", "{\"type\":42}",
|
||||
// attached:非 UUID / v1 / 缺失 / 错型(M7)
|
||||
"{\"type\":\"attached\",\"sessionId\":\"abc123\"}",
|
||||
"{\"type\":\"attached\",\"sessionId\":\"550e8400-e29b-11d4-a716-446655440000\"}",
|
||||
"{\"type\":\"attached\"}",
|
||||
"{\"type\":\"attached\",\"sessionId\":42}",
|
||||
// output:data 缺失 / 错型
|
||||
"{\"type\":\"output\"}",
|
||||
"{\"type\":\"output\",\"data\":42}",
|
||||
// exit:code 缺失 / 字符串 / 非整数
|
||||
"{\"type\":\"exit\"}",
|
||||
"{\"type\":\"exit\",\"code\":\"0\"}",
|
||||
"{\"type\":\"exit\",\"code\":1.5}",
|
||||
// status:status 缺失 / 未知值 / 错型(白名单)
|
||||
"{\"type\":\"status\"}",
|
||||
"{\"type\":\"status\",\"status\":\"sleeping\"}",
|
||||
"{\"type\":\"status\",\"status\":42}",
|
||||
// telemetry:telemetry 键缺失 / 非对象 / at 缺失 / at 错型
|
||||
"{\"type\":\"telemetry\"}",
|
||||
"{\"type\":\"telemetry\",\"telemetry\":\"x\"}",
|
||||
"{\"type\":\"telemetry\",\"telemetry\":{\"costUsd\":1}}",
|
||||
"{\"type\":\"telemetry\",\"telemetry\":{\"at\":\"now\"}}",
|
||||
])
|
||||
func decodeServerReturnsNilForMalformedFrames(frame: String) {
|
||||
#expect(MessageCodec.decodeServer(frame) == nil)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
|
||||
/// Test-side mini-port of the server's `parseClientMessage` (src/protocol.ts:45-176)
|
||||
/// plus `parseApproveMode` (src/server.ts:94-102). Used by the roundtrip property
|
||||
/// test and the cross-implementation vector tests: `MessageCodec.encode` output is
|
||||
/// fed through THIS parser, which itself is pinned against the vectors transcribed
|
||||
/// from test/protocol.test.ts — so the Swift encoder and the TS server parser
|
||||
/// cannot drift silently (the live tripwire is T-iOS-16's real-server CI).
|
||||
enum ServerViewMessage: Equatable {
|
||||
case attach(sessionId: String?, cwd: String?)
|
||||
case input(data: String)
|
||||
case resize(cols: Int, rows: Int)
|
||||
case approve
|
||||
case reject
|
||||
}
|
||||
|
||||
enum ServerViewParser {
|
||||
/// Mirror of src/protocol.ts SESSION_ID_RE (UUID v4, /i).
|
||||
private static let sessionIdPattern =
|
||||
"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||
|
||||
/// Mirror of src/server.ts:76 PERMISSION_MODES.
|
||||
static let permissionModes: Set<String> = ["default", "acceptEdits", "plan", "auto"]
|
||||
|
||||
/// Mirror of src/protocol.ts:27 ALLOWED_TYPES.
|
||||
private static let allowedTypes: Set<String> = ["attach", "input", "resize", "approve", "reject"]
|
||||
|
||||
// MARK: - parseClientMessage mirror (never throws; invalid → nil)
|
||||
|
||||
static func parse(_ raw: String) -> ServerViewMessage? {
|
||||
guard let data = raw.data(using: .utf8),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]),
|
||||
let obj = parsed as? [String: Any],
|
||||
let type = obj["type"] as? String,
|
||||
allowedTypes.contains(type)
|
||||
else { return nil }
|
||||
|
||||
switch type {
|
||||
case "resize": return parseResize(obj)
|
||||
case "input": return parseInput(obj)
|
||||
case "approve": return .approve
|
||||
case "reject": return .reject
|
||||
default: return parseAttach(obj)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror of src/server.ts:94-102 — reads `mode` from the RAW frame's top level.
|
||||
static func topLevelMode(_ raw: String) -> String? {
|
||||
guard let data = raw.data(using: .utf8),
|
||||
let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
||||
let mode = obj["mode"] as? String,
|
||||
permissionModes.contains(mode)
|
||||
else { return nil }
|
||||
return mode
|
||||
}
|
||||
|
||||
// MARK: - Per-type validators (src/protocol.ts:90-176)
|
||||
|
||||
private static func parseResize(_ obj: [String: Any]) -> ServerViewMessage? {
|
||||
guard let cols = integerDimension(obj["cols"]),
|
||||
let rows = integerDimension(obj["rows"])
|
||||
else { return nil }
|
||||
return .resize(cols: cols, rows: rows)
|
||||
}
|
||||
|
||||
/// Mirror of isValidDimension: number, integer, 1...1000 (src/protocol.ts:113-115).
|
||||
private static func integerDimension(_ value: Any?) -> Int? {
|
||||
guard let number = value as? NSNumber, !isJSONBoolean(number) else { return nil }
|
||||
let doubleValue = number.doubleValue
|
||||
guard doubleValue == doubleValue.rounded(.towardZero),
|
||||
doubleValue >= 1, doubleValue <= 1000
|
||||
else { return nil }
|
||||
return number.intValue
|
||||
}
|
||||
|
||||
private static func isJSONBoolean(_ number: NSNumber) -> Bool {
|
||||
CFGetTypeID(number) == CFBooleanGetTypeID()
|
||||
}
|
||||
|
||||
private static func parseInput(_ obj: [String: Any]) -> ServerViewMessage? {
|
||||
guard let data = obj["data"] as? String else { return nil }
|
||||
return .input(data: data)
|
||||
}
|
||||
|
||||
private static func parseAttach(_ obj: [String: Any]) -> ServerViewMessage? {
|
||||
// sessionId key must be explicitly present (src/protocol.ts:132-134).
|
||||
guard obj.index(forKey: "sessionId") != nil else { return nil }
|
||||
|
||||
var cwd: String?
|
||||
if let rawCwd = obj["cwd"] {
|
||||
guard let cwdString = rawCwd as? String, cwdString.hasPrefix("/") else { return nil }
|
||||
cwd = cwdString
|
||||
}
|
||||
|
||||
if obj["sessionId"] is NSNull {
|
||||
return .attach(sessionId: nil, cwd: cwd)
|
||||
}
|
||||
guard let sessionId = obj["sessionId"] as? String, isServerValidSessionId(sessionId) else {
|
||||
return nil
|
||||
}
|
||||
return .attach(sessionId: sessionId, cwd: cwd)
|
||||
}
|
||||
|
||||
static func isServerValidSessionId(_ candidate: String) -> Bool {
|
||||
candidate.range(
|
||||
of: sessionIdPattern,
|
||||
options: [.regularExpression, .caseInsensitive]
|
||||
) != nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
/// Deterministic RNG for the roundtrip property test (reproducible failures:
|
||||
/// the seed is baked into the test; change it only with a note in the test).
|
||||
struct SplitMix64: RandomNumberGenerator {
|
||||
private var state: UInt64
|
||||
|
||||
init(seed: UInt64) {
|
||||
state = seed
|
||||
}
|
||||
|
||||
mutating func next() -> UInt64 {
|
||||
state &+= 0x9E37_79B9_7F4A_7C15
|
||||
var z = state
|
||||
z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
|
||||
z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
|
||||
return z ^ (z >> 31)
|
||||
}
|
||||
}
|
||||
|
||||
enum MessageGen {
|
||||
/// Character pool that stresses the JSON escaper: quotes, backslashes,
|
||||
/// C0 control bytes (ESC / CR / TAB / ^C / NUL), CJK, emoji, plain ASCII.
|
||||
private static let stressPool: [Character] = [
|
||||
"a", "Z", "0", " ", "\"", "\\", "/", "{", "}",
|
||||
"\u{1B}", "\r", "\n", "\t", "\u{03}", "\u{00}", "\u{7F}",
|
||||
"中", "文", "終", "端", "🚀", "🧪", "é", "ß",
|
||||
]
|
||||
|
||||
static func randomString(using rng: inout some RandomNumberGenerator, maxLength: Int = 48) -> String {
|
||||
let length = Int.random(in: 0...maxLength, using: &rng)
|
||||
return String((0..<length).map { _ in stressPool.randomElement(using: &rng)! })
|
||||
}
|
||||
|
||||
static func randomAbsolutePath(using rng: inout some RandomNumberGenerator) -> String {
|
||||
"/" + randomString(using: &rng, maxLength: 24)
|
||||
}
|
||||
|
||||
/// Seeded UUID v4 (version nibble 4, variant 10xx) — passes SESSION_ID_RE.
|
||||
static func randomUUIDv4(using rng: inout some RandomNumberGenerator) -> UUID {
|
||||
var bytes = (0..<16).map { _ in UInt8.random(in: 0...255, using: &rng) }
|
||||
bytes[6] = (bytes[6] & 0x0F) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3F) | 0x80
|
||||
return UUID(uuid: (
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
|
||||
))
|
||||
}
|
||||
|
||||
static func randomClientMessage(using rng: inout some RandomNumberGenerator) -> ClientMessage {
|
||||
switch Int.random(in: 0...4, using: &rng) {
|
||||
case 0:
|
||||
let sessionId = Bool.random(using: &rng) ? randomUUIDv4(using: &rng) : nil
|
||||
let cwd = Bool.random(using: &rng) ? randomAbsolutePath(using: &rng) : nil
|
||||
return .attach(sessionId: sessionId, cwd: cwd)
|
||||
case 1:
|
||||
return .input(data: randomString(using: &rng))
|
||||
case 2:
|
||||
return .resize(
|
||||
cols: Int.random(in: 1...1000, using: &rng),
|
||||
rows: Int.random(in: 1...1000, using: &rng)
|
||||
)
|
||||
case 3:
|
||||
let mode = Bool.random(using: &rng) ? ApproveMode.allCases.randomElement(using: &rng) : nil
|
||||
return .approve(mode: mode)
|
||||
default:
|
||||
return .reject
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import WireProtocol
|
||||
|
||||
// T-iOS-3 · TimelineEvent(GET /live-sessions/:id/events 条目,src/types.ts:428-433 镜像)。
|
||||
// 服务器是不可信输入源:decodeList 永不 throw,非法/未知 class 条目静默丢弃。
|
||||
|
||||
@Test("TimelineEvent:合法条目全字段解码")
|
||||
func decodesValidEntryWithAllFields() throws {
|
||||
// Arrange
|
||||
let json = Data("{\"at\":1751600000000,\"class\":\"tool\",\"toolName\":\"Bash\",\"label\":\"ran Bash\"}".utf8)
|
||||
|
||||
// Act
|
||||
let event = try JSONDecoder().decode(TimelineEvent.self, from: json)
|
||||
|
||||
// Assert
|
||||
#expect(event == TimelineEvent(at: 1_751_600_000_000, class: "tool", toolName: "Bash", label: "ran Bash"))
|
||||
#expect(event.hasKnownClass)
|
||||
}
|
||||
|
||||
@Test("TimelineEvent:toolName 可选缺省")
|
||||
func decodesEntryWithoutToolName() throws {
|
||||
// Arrange
|
||||
let json = Data("{\"at\":1,\"class\":\"waiting\",\"label\":\"waiting for approval\"}".utf8)
|
||||
|
||||
// Act
|
||||
let event = try JSONDecoder().decode(TimelineEvent.self, from: json)
|
||||
|
||||
// Assert
|
||||
#expect(event.toolName == nil)
|
||||
#expect(event.hasKnownClass)
|
||||
}
|
||||
|
||||
@Test("knownClasses 与服务器 TimelineClass 全集一致(src/types.ts:423)")
|
||||
func knownClassesMirrorServerUnion() {
|
||||
#expect(TimelineEvent.knownClasses == ["tool", "waiting", "done", "stuck", "user"])
|
||||
}
|
||||
|
||||
@Test("decodeList:未知 class 条目被丢弃,合法条目保留(消费方丢弃语义)")
|
||||
func decodeListDropsUnknownClassEntries() {
|
||||
// Arrange — 1 合法 + 1 未知 class + 1 缺 label + 1 形状完全错误
|
||||
let json = Data("""
|
||||
[{"at":1,"class":"tool","label":"ran Bash"},
|
||||
{"at":2,"class":"alien","label":"???"},
|
||||
{"at":3,"class":"done"},
|
||||
42]
|
||||
""".utf8)
|
||||
|
||||
// Act
|
||||
let events = TimelineEvent.decodeList(from: json)
|
||||
|
||||
// Assert
|
||||
#expect(events == [TimelineEvent(at: 1, class: "tool", toolName: nil, label: "ran Bash")])
|
||||
}
|
||||
|
||||
@Test("decodeList:5 个已知 class 全部保留")
|
||||
func decodeListKeepsAllKnownClasses() {
|
||||
// Arrange
|
||||
let json = Data("""
|
||||
[{"at":1,"class":"tool","label":"a"},{"at":2,"class":"waiting","label":"b"},
|
||||
{"at":3,"class":"done","label":"c"},{"at":4,"class":"stuck","label":"d"},
|
||||
{"at":5,"class":"user","label":"e"}]
|
||||
""".utf8)
|
||||
|
||||
// Act
|
||||
let events = TimelineEvent.decodeList(from: json)
|
||||
|
||||
// Assert
|
||||
#expect(events.count == 5)
|
||||
#expect(events.allSatisfy { $0.hasKnownClass })
|
||||
}
|
||||
|
||||
@Test("decodeList:坏 JSON / 顶层非数组 → 空数组,永不 throw",
|
||||
arguments: ["not json", "{\"at\":1}", "42", ""])
|
||||
func decodeListNeverThrowsOnMalformedInput(text: String) {
|
||||
#expect(TimelineEvent.decodeList(from: Data(text.utf8)) == [])
|
||||
}
|
||||
93
ios/project.yml
Normal file
93
ios/project.yml
Normal file
@@ -0,0 +1,93 @@
|
||||
# XcodeGen project spec — regenerate with: cd ios && xcodegen generate
|
||||
# The generated WebTerm.xcodeproj is NOT committed (see ios/.gitignore).
|
||||
#
|
||||
# TOOLCHAIN DEVIATION (recorded per plan §7 T-iOS-1): the plan names
|
||||
# "default MainActor isolation" (SWIFT_DEFAULT_ACTOR_ISOLATION), which is
|
||||
# Swift 6.2+/Xcode 26 only. On Swift 6.1 / Xcode 16.3 the closest supported
|
||||
# equivalent is Swift 6 language mode + strict concurrency, with explicit
|
||||
# @MainActor annotations where isolation is required.
|
||||
name: WebTerm
|
||||
|
||||
options:
|
||||
bundleIdPrefix: com.yaojia
|
||||
deploymentTarget:
|
||||
iOS: "17.0"
|
||||
createIntermediateGroups: true
|
||||
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "6.0" # Swift 6 language mode
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
IPHONEOS_DEPLOYMENT_TARGET: "17.0"
|
||||
TARGETED_DEVICE_FAMILY: "1" # iPhone only (iPad layout is out of v1 scope)
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
|
||||
packages:
|
||||
WireProtocol:
|
||||
path: Packages/WireProtocol
|
||||
SessionCore:
|
||||
path: Packages/SessionCore
|
||||
HostRegistry:
|
||||
path: Packages/HostRegistry
|
||||
APIClient:
|
||||
path: Packages/APIClient
|
||||
# SwiftTerm is the ONLY third-party dependency, attached to the App target
|
||||
# ONLY (plan §2) — packages must never import it.
|
||||
SwiftTerm:
|
||||
url: https://github.com/migueldeicaza/SwiftTerm
|
||||
from: 1.13.0
|
||||
|
||||
targets:
|
||||
WebTerm:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- App/WebTerm
|
||||
dependencies:
|
||||
- package: WireProtocol
|
||||
- package: SessionCore
|
||||
- package: HostRegistry
|
||||
- package: APIClient
|
||||
- package: SwiftTerm
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.yaojia.webterm
|
||||
info:
|
||||
path: App/WebTerm/Resources/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: WebTerm
|
||||
UILaunchScreen: {}
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationLandscapeLeft
|
||||
- UIInterfaceOrientationLandscapeRight
|
||||
# ── Security-critical keys, transcribed verbatim from PLAN_IOS_CLIENT §5.2 ──
|
||||
# NO NSAllowsArbitraryLoads anywhere (release OR debug): the five CIDR
|
||||
# exceptions below cover LAN + hotspot + Tailscale CGNAT + simulator
|
||||
# loopback, so no arbitrary-loads escape hatch is needed in P0.
|
||||
# T-iOS-19 re-verifies all five CIDR blocks in the release ipa.
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsLocalNetworking: true # only covers .local / dotless hostnames, not bare IPs
|
||||
NSExceptionDomains: # bare IPs use CIDR exceptions (iOS 17+ semantics)
|
||||
"192.168.0.0/16":
|
||||
NSExceptionAllowsInsecureHTTPLoads: true
|
||||
"10.0.0.0/8":
|
||||
NSExceptionAllowsInsecureHTTPLoads: true
|
||||
"172.16.0.0/12": # RFC1918 third block (iPhone hotspot 172.20.10.x / corp LAN)
|
||||
NSExceptionAllowsInsecureHTTPLoads: true
|
||||
"100.64.0.0/10": # Tailscale CGNAT
|
||||
NSExceptionAllowsInsecureHTTPLoads: true
|
||||
"127.0.0.0/8": # simulator dev-loop (CIDR, not a single-IP key — DevForums 6205)
|
||||
NSExceptionAllowsInsecureHTTPLoads: true
|
||||
NSLocalNetworkUsageDescription: "连接你自己电脑上的 web-terminal 服务器"
|
||||
NSCameraUsageDescription: "扫描 web 终端的配对二维码"
|
||||
|
||||
schemes:
|
||||
WebTerm:
|
||||
build:
|
||||
targets:
|
||||
WebTerm: all
|
||||
run:
|
||||
config: Debug
|
||||
test:
|
||||
config: Debug
|
||||
Reference in New Issue
Block a user