Files
web-terminal/ios/IntegrationTests/ServerHarness.swift
Yaojia Wang ddab77b337 test(ios): close T-iOS-32's end-to-end half — real worktree lifecycle against a real server
T-iOS-32's acceptance is "builder 测试 + 端到端一次". The builder half was green,
but grep for projects/worktree across the whole test tree returned nothing — no
test had ever created or removed a real git worktree against a real server.

Six tests on a throwaway git fixture: create/remove/prune verified against both
the filesystem and git's own `worktree list --porcelain` + .git/worktrees/, and
asserting the values the SERVER derives rather than echoing our input (branch
feature/e2e-worktree becomes directory feature-e2e-worktree; the raw branch name
must never appear in the path). Includes a differential leg for the guarded-route
rule — the same request without Origin is 403 with zero side effects, and with it
is 200 — which needs raw URLSession, since APIClient structurally cannot emit an
Origin-less guarded request.

GET /sessions gets its own HOME-isolated server: history.ts reads
os.homedir()/.claude/projects, so against the shared harness the assertions would
land on the developer's real Claude history, and in CI the directory is absent so
it degrades to [] and proves nothing.

Integration tests 26 -> 32, independently re-run.
2026-07-30 16:58:22 +02:00

426 lines
21 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// ServerHarness.swift T-iOS-16 Node
//
// T-iOS-2 OriginSpikeTests.swift591 plan §7 T-iOS-16
// harness + suites
// - spawn `node_modules/.bin/tsx src/server.ts`
// suite
// - GET /live-sessions JSON
// - death-pipe watchdog + atexit Swift Testing runner 退
// atexitwatchdog
//
//
// A. `swift test --package-path ios/IntegrationTests`
// repo #filePath WEBTERM_REPO_ROOT 127.0.0.1
// SHELL_PATH=/bin/bashUSE_TMUX=0
// ALLOWED_ORIGINS deriveAllowedOrigins
// http://127.0.0.1:<port>src/config.ts:187-226
// $TMPDIR/webterm-integration-server-<port>.log
// B. WEBTERM_SERVER_URL=http://127.0.0.1:<port> loopback
// origin
//
// F3 **** `WEBTERM_TOKEN`
// `ServerHarness.tokenGatedServer()``POST /auth`
// 204 Set-Cookie**** 401/
// ****
// A. §1.1 /**** env
//
// URL
// B. WEBTERM_TOKEN_SERVER_URL + WEBTERM_TOKEN_SERVER_TOKEN
// worktree node_modules checkout
//
// spike WireProtocol T-iOS-3Origin/wsURL
// `HostEndpoint` plan §5.1
import Darwin
import Foundation
import WireProtocol
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
// 线 WireProtocol.Tunables/WireConstants
enum HarnessTunables {
/// > URLSessionWebSocketTask 1 MiB1_048_576
static let bulkOutputBytes = 1_500_000
/// "\u{1B}[0m"4 1.6 MB
/// JSON ×2.25 3.6 MB>1MiB<16MiB<2MiB ring
static let escSequenceRepeats = 400_000
static let escSequenceRawBytes = escSequenceRepeats * 4
/// Origin 退 fallback
static let mismatchedOriginPort = 9999
static let mismatchedOriginPortFallback = 9998
static let serverReadyTimeout: Duration = .seconds(40)
static let serverReadyPollInterval: Duration = .milliseconds(200)
/// attachattached
static let frameTimeout: Duration = .seconds(15)
/// shell bash
static let markerTimeout: Duration = .seconds(30)
/// >1MiB /
static let outputAccumulationTimeout: Duration = .seconds(90)
/// WS kill
static let closeObserveTimeout: Duration = .seconds(30)
/// F3 `ReconnectMachine` 退1s
/// """"3.5s 1s + 2s
static let retryObservationWindow: Duration = .milliseconds(3500)
}
enum HarnessError: Error, CustomStringConvertible {
case setup(String)
case timeout(String)
var description: String {
switch self {
case .setup(let detail): return "setup: \(detail)"
case .timeout(let detail): return "timeout: \(detail)"
}
}
}
// F3
/// `WEBTERM_TOKEN` +
///
/// §1.1`token` `URLRequest`/`tokenProvider`
/// ****URL query
struct TokenGatedServer: Sendable {
let server: TestServer
let token: String
/// **** 401
/// `AuthCookie.headerValue` nil
///
var wrongButWellFormedToken: String {
// /
let replacement: Character = token.first == "z" ? "y" : "z"
return String(replacement) + token.dropFirst()
}
}
/// 32 §1.1 ASCII
/// TokenPolicyDriftTests
func makeThrowawayToken(length: Int = 32) -> String {
let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
var generator = SystemRandomNumberGenerator()
let characters = (0..<length).map { _ -> Character in
alphabet[Int.random(in: 0..<alphabet.count, using: &generator)]
}
return String(characters)
}
// HostEndpoint
struct TestServer: Sendable {
let endpoint: HostEndpoint
var baseURL: URL { endpoint.baseURL }
var wsURL: URL { endpoint.wsURL }
/// Origin HostEndpoint plan §5.1
var origin: String { endpoint.originHeader }
var port: Int { endpoint.baseURL.port ?? (endpoint.baseURL.scheme == "https" ? 443 : 80) }
/// + /env
static func make(baseURL: URL) throws -> TestServer {
guard let endpoint = HostEndpoint(baseURL: baseURL) else {
throw HarnessError.setup("server URL 必须是带 host 的 http(s)got \(baseURL)")
}
return TestServer(endpoint: endpoint)
}
}
// death-pipe watchdog + atexit tsx
//
// T-iOS-2Swift Testing runner 退 atexit handler
// tsx death-pipespawn /bin/sh watchdog stdin
// 退/
// watchdog `read` EOF kill pidatexit
private enum ServerProcessRegistry {
private static let lock = NSLock()
/// F3 +
/// register deathPipe deinit
/// watchdog EOF
nonisolated(unsafe) private static var processes: [Process] = []
/// deinit watchdog
nonisolated(unsafe) private static var deathPipes: [Pipe] = []
nonisolated(unsafe) private static var installedAtexit = false
static func register(_ p: Process) {
lock.lock()
defer { lock.unlock() }
processes.append(p)
if let pipe = try? spawnDeathWatchdog(serverPid: p.processIdentifier) {
deathPipes.append(pipe)
}
guard !installedAtexit else { return }
installedAtexit = true
atexit { ServerProcessRegistry.killNow() }
}
static func killNow() {
lock.lock()
defer { lock.unlock() }
for p in processes where p.isRunning { p.terminate() }
processes = []
}
private static func spawnDeathWatchdog(serverPid: Int32) throws -> Pipe {
let pipe = Pipe()
let watchdog = Process()
watchdog.executableURL = URL(fileURLWithPath: "/bin/sh")
watchdog.arguments = ["-c", "read _; kill \(serverPid) 2>/dev/null"]
watchdog.standardInput = pipe
watchdog.standardOutput = FileHandle.nullDevice
watchdog.standardError = FileHandle.nullDevice
try watchdog.run()
return pipe
}
}
// harnessactor spawn suite
//
// Swift Testing suite suite server()actor
// awaitReady ""
// spawn in-flight Task await
actor ServerHarness {
static let shared = ServerHarness()
private var bootTask: Task<TestServer, any Error>?
private var tokenBootTask: Task<TokenGatedServer, any Error>?
func server() async throws -> TestServer {
if let bootTask { return try await bootTask.value }
let task = Task { try await Self.bootstrap() }
bootTask = task
return try await task.value
}
/// F3 `WEBTERM_TOKEN` " suite "
func tokenGatedServer() async throws -> TokenGatedServer {
if let tokenBootTask { return try await tokenBootTask.value }
let task = Task { try await Self.bootstrapTokenGated() }
tokenBootTask = task
return try await task.value
}
private static func bootstrap() async throws -> TestServer {
if let external = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"] {
guard let url = URL(string: external) else {
throw HarnessError.setup("WEBTERM_SERVER_URL 不是合法 URL: \(external)")
}
let server = try TestServer.make(baseURL: url)
try await awaitReady(server, process: nil, logURL: nil, cookie: nil)
return server
}
let spawned = try spawnLocalServer()
ServerProcessRegistry.register(spawned.process)
do {
try await awaitReady(
spawned.server, process: spawned.process, logURL: spawned.logURL, cookie: nil)
} catch {
ServerProcessRegistry.killNow()
throw error
}
return spawned.server
}
private static func bootstrapTokenGated() async throws -> TokenGatedServer {
let environment = ProcessInfo.processInfo.environment
if let external = environment["WEBTERM_TOKEN_SERVER_URL"] {
guard let token = environment["WEBTERM_TOKEN_SERVER_TOKEN"], !token.isEmpty else {
throw HarnessError.setup(
"设了 WEBTERM_TOKEN_SERVER_URL 就必须同时设 WEBTERM_TOKEN_SERVER_TOKEN")
}
guard let url = URL(string: external) else {
throw HarnessError.setup("WEBTERM_TOKEN_SERVER_URL 不是合法 URL: \(external)")
}
let server = try TestServer.make(baseURL: url)
let gated = TokenGatedServer(server: server, token: token)
// cookie GET /live-sessions cookie 401
try await awaitReady(
server, process: nil, logURL: nil, cookie: authCookieHeader(token: token))
return gated
}
let token = makeThrowawayToken()
let spawned = try spawnLocalServer(extraEnvironment: ["WEBTERM_TOKEN": token])
ServerProcessRegistry.register(spawned.process)
do {
try await awaitReady(
spawned.server, process: spawned.process, logURL: spawned.logURL,
cookie: authCookieHeader(token: token))
} catch {
ServerProcessRegistry.killNow()
throw error
}
return TokenGatedServer(server: spawned.server, token: token)
}
/// T-iOS-32**** bespoke
///
/// `server()``GET /sessions`
/// `os.homedir()/.claude/projects`src/http/history.ts:81
/// `HOME` **** Claude
/// CI `[]`
/// `HOME` env
/// `tokenGatedServer()`
///
/// shared **** `ServerProcessRegistry`
/// death-pipe watchdog tsx
/// `terminate()` `killNow()`
/// suite
///
/// `WEBTERM_SERVER_URL` `HOME`
///
static func disposableServer(extraEnvironment: [String: String]) async throws -> TestServer {
let spawned = try spawnLocalServer(extraEnvironment: extraEnvironment)
ServerProcessRegistry.register(spawned.process)
do {
try await awaitReady(
spawned.server, process: spawned.process, logURL: spawned.logURL, cookie: nil)
} catch {
spawned.process.terminate()
throw error
}
return spawned.server
}
private static func spawnLocalServer(
extraEnvironment: [String: String] = [:]
) throws -> (server: TestServer, process: Process, logURL: URL) {
let repoRoot = try locateRepoRoot()
let tsx = try locateTsx(from: repoRoot)
let port = try findFreeLoopbackPort()
guard let baseURL = URL(string: "http://127.0.0.1:\(port)") else {
throw HarnessError.setup("无法构造 baseURL, port=\(port)")
}
let server = try TestServer.make(baseURL: baseURL)
let logURL = FileManager.default.temporaryDirectory
.appending(path: "webterm-integration-server-\(port).log")
FileManager.default.createFile(atPath: logURL.path, contents: nil)
let logHandle = try FileHandle(forWritingTo: logURL)
let process = Process()
process.executableURL = tsx
process.arguments = ["src/server.ts"]
process.currentDirectoryURL = repoRoot
var env = ProcessInfo.processInfo.environment
env["PORT"] = String(port)
env["BIND_HOST"] = "127.0.0.1" // 0.0.0.0
env["SHELL_PATH"] = "/bin/bash" // zsh
env["USE_TMUX"] = "0"
env["PATH"] = (env["PATH"] ?? "") + ":/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
// F3 env `WEBTERM_TOKEN`
// `ps` env 西
for (key, value) in extraEnvironment { env[key] = value }
process.environment = env
process.standardOutput = logHandle
process.standardError = logHandle
try process.run()
return (server, process, logURL)
}
/// - Parameter cookie: `Cookie: webterm_auth=`
/// `GET /live-sessions` 401src/server.ts:459""
private static func awaitReady(
_ server: TestServer, process: Process?, logURL: URL?, cookie: String?
) async throws {
var probe = URLRequest(url: server.baseURL.appending(path: "live-sessions"))
if let cookie { probe.setValue(cookie, forHTTPHeaderField: "Cookie") }
let deadline = ContinuousClock.now + HarnessTunables.serverReadyTimeout
while ContinuousClock.now < deadline {
if let process, !process.isRunning {
throw HarnessError.setup(
"服务器进程提前退出exit \(process.terminationStatus))— 日志: \(logURL?.path ?? "n/a")")
}
if let (data, response) = try? await cookieFreeSession.data(for: probe),
(response as? HTTPURLResponse)?.statusCode == 200,
(try? JSONSerialization.jsonObject(with: data)) is [Any] {
return
}
try await Task.sleep(for: HarnessTunables.serverReadyPollInterval)
}
throw HarnessError.timeout(
"服务器 \(server.baseURL)\(HarnessTunables.serverReadyTimeout) 内未就绪 — 日志: \(logURL?.path ?? "n/a")")
}
}
/// `node_modules/.bin/tsx` repo ****
///
/// `.claude/worktrees/<name>/` worktree
/// `node_modules`Node worktree
/// `src/server.ts` checkout node_modules
/// ""
func locateTsx(from repoRoot: URL) throws -> URL {
var directory = repoRoot.standardizedFileURL
var visited: [String] = []
while true {
let candidate = directory.appending(path: "node_modules/.bin/tsx")
if FileManager.default.isExecutableFile(atPath: candidate.path) { return candidate }
visited.append(candidate.path)
let parent = directory.deletingLastPathComponent().standardizedFileURL
if parent.path == directory.path { break }
directory = parent
}
throw HarnessError.setup(
"找不到 node_modules/.bin/tsx逐级向上查过 \(visited.count) 处,"
+ "\(visited.first ?? "?") 起)— 先在 repo 根目录跑 npm install/npm ci")
}
/// `Cookie: webterm_auth=<token>` **** `AUTH_COOKIE_NAME`
/// TokenPolicyDriftTests src/http/auth.ts
func authCookieHeader(token: String) -> String {
"webterm_auth=\(token)"
}
/// cookie jar `Cookie` §1.1
/// `Set-Cookie`
/// `SessionCore.WSConnection.sessionConfiguration`
let cookieFreeSession: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.httpShouldSetCookies = false
configuration.httpCookieAcceptPolicy = .never
configuration.httpCookieStorage = nil
return URLSession(configuration: configuration)
}()
func locateRepoRoot() throws -> URL {
if let override = ProcessInfo.processInfo.environment["WEBTERM_REPO_ROOT"] {
return URL(fileURLWithPath: override, isDirectory: true)
}
// #filePath = <repo>/ios/IntegrationTests/ServerHarness.swift
return URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // IntegrationTests
.deletingLastPathComponent() // ios
.deletingLastPathComponent() // repo root
}
/// bind(port=0) loopback close
/// awaitReady
func findFreeLoopbackPort() throws -> Int {
let fd = socket(AF_INET, SOCK_STREAM, 0)
guard fd >= 0 else { throw HarnessError.setup("socket() 失败: errno \(errno)") }
defer { close(fd) }
var addr = sockaddr_in()
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = 0
addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
let bound = withUnsafePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
guard bound == 0 else { throw HarnessError.setup("bind() 失败: errno \(errno)") }
var out = sockaddr_in()
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
let named = withUnsafeMutablePointer(to: &out) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(fd, $0, &len) }
}
guard named == 0 else { throw HarnessError.setup("getsockname() 失败: errno \(errno)") }
return Int(UInt16(bigEndian: out.sin_port))
}