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:
Yaojia Wang
2026-07-04 21:19:30 +02:00
parent 9b41ffa574
commit cbaa08daba
46 changed files with 3203 additions and 3 deletions

View 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")
}

View 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
// Originhttp://127.0.0.1:9999 401src/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/bashUSE_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.TunablesT-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 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
/// 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-pipespawn /bin/sh watchdog stdin
// 退/
// watchdog `read` EOF kill pidatexit
private enum ServerProcessRegistry {
private static let lock = NSLock()
nonisolated(unsafe) private static var process: Process?
/// deinit watchdog
nonisolated(unsafe) private static var deathPipe: Pipe?
nonisolated(unsafe) private static var installedAtexit = false
static func register(_ p: Process) {
lock.lock()
defer { lock.unlock() }
process = p
deathPipe = try? spawnDeathWatchdog(serverPid: p.processIdentifier)
guard !installedAtexit else { return }
installedAtexit = true
atexit { ServerProcessRegistry.killNow() }
}
static func killNow() {
lock.lock()
defer { lock.unlock() }
if let p = process, p.isRunning { p.terminate() }
process = nil
}
private static func spawnDeathWatchdog(serverPid: Int32) throws -> Pipe {
let pipe = Pipe()
let watchdog = Process()
watchdog.executableURL = URL(fileURLWithPath: "/bin/sh")
watchdog.arguments = ["-c", "read _; kill \(serverPid) 2>/dev/null"]
watchdog.standardInput = pipe
watchdog.standardOutput = FileHandle.nullDevice
watchdog.standardError = FileHandle.nullDevice
try watchdog.run()
return pipe
}
}
// harnessactor spawn suite
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 headerspike
/// - maxMessageBytes: nil = 1 MiBspike
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
}
}
}
}
/// / nilhandshake
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 \r0x0D \nCLAUDE.md gotcha
private func inputFrame(command: String) -> [String: Any] {
["type": "input", "data": command + "\r"]
}
/// attach attached output/statusattachWs attached
/// snapshotsrc/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[0msrc/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 40Darwin 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 Origin403
/// 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 ("")
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")")
}
}

View 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]
)