Files
1jehuang--jcode/ios/Tests/JCodeKitTests/ConnectionTests.swift
T
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

266 lines
8.3 KiB
Swift

import Foundation
import Testing
@testable import JCodeKit
/// Scriptable in-memory transport for Connection tests.
actor FakeTransport: WebSocketTransport {
enum Behavior {
case succeed
case failConnect
case unauthorized
}
let behavior: Behavior
private(set) var sentLines: [String] = []
private var incoming: [String] = []
private var waiters: [CheckedContinuation<String?, Never>] = []
private var closed = false
init(behavior: Behavior = .succeed) {
self.behavior = behavior
}
func connect(url: URL, authToken: String) async throws {
if behavior == .failConnect {
throw TransportError.notConnected
}
if behavior == .unauthorized {
throw TransportError.unauthorized
}
}
func send(text: String) async throws {
if closed { throw TransportError.notConnected }
sentLines.append(text)
}
func receiveText() async throws -> String? {
if closed { return nil }
if !incoming.isEmpty {
return incoming.removeFirst()
}
return await withCheckedContinuation { continuation in
waiters.append(continuation)
}
}
func close() async {
closed = true
for waiter in waiters {
waiter.resume(returning: nil)
}
waiters.removeAll()
}
/// Test helper: push a server frame to the client.
func push(_ line: String) {
if let waiter = waiters.first {
waiters.removeFirst()
waiter.resume(returning: line)
} else {
incoming.append(line)
}
}
}
private func makeConnection(transport: FakeTransport) -> Connection {
Connection(
configuration: .init(
gateway: Gateway(host: "test.local"),
authToken: "tok",
maxReconnectAttempts: 1,
baseBackoffSeconds: 0.01
),
makeTransport: { transport }
)
}
@Test func connectSubscribesAndSyncsHistory() async throws {
let transport = FakeTransport()
let connection = makeConnection(transport: transport)
let stream = await connection.start()
var iterator = stream.makeAsyncIterator()
// connecting -> connected
#expect(await iterator.next() == .phase(.connecting))
#expect(await iterator.next() == .phase(.connected))
// Wait for the subscribe + get_history requests to land.
var sent: [String] = []
for _ in 0..<50 {
sent = await transport.sentLines
if sent.count >= 2 { break }
try await Task.sleep(nanoseconds: 10_000_000)
}
#expect(sent.count == 2)
#expect(sent[0].contains("\"type\":\"subscribe\""))
#expect(sent[1].contains("\"type\":\"get_history\""))
await connection.stop()
}
@Test func decodesPushedEvents() async throws {
let transport = FakeTransport()
let connection = makeConnection(transport: transport)
let stream = await connection.start()
var iterator = stream.makeAsyncIterator()
#expect(await iterator.next() == .phase(.connecting))
#expect(await iterator.next() == .phase(.connected))
await transport.push(#"{"type":"text_delta","text":"hi"}"#)
#expect(await iterator.next() == .event(.textDelta(text: "hi")))
// Multiple newline-delimited events in one frame.
await transport.push("{\"type\":\"message_end\"}\n{\"type\":\"done\",\"id\":1}")
#expect(await iterator.next() == .event(.messageEnd))
#expect(await iterator.next() == .event(.done(id: 1)))
await connection.stop()
}
@Test func sendAssignsMonotonicRequestIDs() async throws {
let transport = FakeTransport()
let connection = makeConnection(transport: transport)
let stream = await connection.start()
var iterator = stream.makeAsyncIterator()
_ = await iterator.next()
_ = await iterator.next()
// Wait until the automatic subscribe/get_history sends (IDs 1-2) finish
// so they cannot interleave with our test sends.
for _ in 0..<100 {
if await transport.sentLines.count >= 2 { break }
try await Task.sleep(nanoseconds: 5_000_000)
}
let first = try await connection.send { .message(id: $0, content: "a") }
let second = try await connection.send { .ping(id: $0) }
#expect(second == first + 1)
await connection.stop()
}
@Test func failedConnectReportsFailureAfterRetries() async throws {
let transport = FakeTransport(behavior: .failConnect)
let connection = makeConnection(transport: transport)
let stream = await connection.start()
var phases: [ConnectionPhase] = []
for await output in stream {
if case let .phase(phase) = output {
phases.append(phase)
if case .failed = phase { break }
}
}
#expect(phases.first == .connecting)
#expect(phases.contains(.reconnecting(attempt: 1)))
if case .failed = phases.last {
} else {
Issue.record("expected failed phase, got \(phases)")
}
await connection.stop()
}
@Test func trackSessionIDForResubscribe() async throws {
let transport = FakeTransport()
let connection = makeConnection(transport: transport)
let stream = await connection.start()
var iterator = stream.makeAsyncIterator()
_ = await iterator.next() // connecting
_ = await iterator.next() // connected
await transport.push(#"{"type":"session","session_id":"sess_42"}"#)
#expect(await iterator.next() == .event(.sessionID(sessionID: "sess_42")))
await connection.stop()
}
@Test func sessionCloseRequestStopsReconnecting() async throws {
let transport = FakeTransport()
let connection = makeConnection(transport: transport)
let stream = await connection.start()
var iterator = stream.makeAsyncIterator()
#expect(await iterator.next() == .phase(.connecting))
#expect(await iterator.next() == .phase(.connected))
await transport.push(#"{"type":"session_close_requested","reason":"taken over"}"#)
#expect(
await iterator.next()
== .event(.sessionCloseRequested(reason: "taken over")))
// The loop must terminate with a failed phase instead of reconnecting.
var sawFailed = false
while let output = await iterator.next() {
if case .phase(.reconnecting) = output {
Issue.record("must not reconnect after session_close_requested")
break
}
if case .phase(.failed(let reason)) = output {
#expect(reason == "taken over")
sawFailed = true
break
}
}
#expect(sawFailed)
await connection.stop()
}
@Test func reloadingTriggersFastReconnect() async throws {
let transport = FakeTransport()
let connection = makeConnection(transport: transport)
let stream = await connection.start()
var iterator = stream.makeAsyncIterator()
#expect(await iterator.next() == .phase(.connecting))
#expect(await iterator.next() == .phase(.connected))
await transport.push(#"{"type":"reloading"}"#)
#expect(await iterator.next() == .event(.reloading(newSocket: nil)))
// Simulate the server dropping the socket for the restart.
await transport.close()
// The connection reconnects (the shared FakeTransport accepts again).
var phases: [ConnectionPhase] = []
while let output = await iterator.next() {
if case let .phase(phase) = output {
phases.append(phase)
if phase == .connected { break }
}
}
#expect(phases.contains(.reconnecting(attempt: 1)))
#expect(phases.last == .connected)
await connection.stop()
}
@Test func unauthorizedStopsReconnectingAndAsksForRePair() async throws {
let transport = FakeTransport(behavior: .unauthorized)
let connection = Connection(
configuration: .init(
gateway: Gateway(host: "test.local"),
authToken: "tok",
maxReconnectAttempts: nil, // would retry forever without the 401 short-circuit
baseBackoffSeconds: 0.01
),
makeTransport: { transport }
)
let stream = await connection.start()
var iterator = stream.makeAsyncIterator()
#expect(await iterator.next() == .phase(.connecting))
let final = await iterator.next()
guard case .phase(.failed(let reason))? = final else {
Issue.record("expected .failed phase, got \(String(describing: final))")
return
}
#expect(reason.contains("Re-pair"))
await connection.stop()
}