459 lines
20 KiB
Swift
459 lines
20 KiB
Swift
// SessionConnectionLifecycleTests.swift
|
|
// T-2.10 — Background lifecycle: suspend / resume / stale-frame-freeze /
|
|
// keep-alive foreground-only / idempotency / reconnect latency.
|
|
//
|
|
// Strategy: tests compile against stub extensions at the bottom of this file.
|
|
// The stubs return sentinel values that make every T-2.10-specific assertion
|
|
// fail. The impl agent replaces the stubs with real implementations inside
|
|
// SessionConnection.swift.
|
|
|
|
import Combine
|
|
import XCTest
|
|
@testable import piRemote
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func fakeCredential() -> SidecarCredential {
|
|
SidecarCredential(
|
|
sidecarId: "test-sidecar",
|
|
host: "127.0.0.1",
|
|
port: 19991, // unreachable localhost port — no real WS needed
|
|
bearerToken: "test-token",
|
|
tlsFingerprint: "deadbeef",
|
|
sidecarName: "test",
|
|
pairedAt: Date()
|
|
)
|
|
}
|
|
|
|
// MARK: - Test class
|
|
|
|
@MainActor
|
|
final class SessionConnectionLifecycleTests: XCTestCase {
|
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
override func tearDown() async throws {
|
|
cancellables.removeAll()
|
|
}
|
|
|
|
// =========================================================================
|
|
// MARK: 1. Suspend / Resume basic state transitions
|
|
// =========================================================================
|
|
|
|
/// Regression: suspend() always leaves state as .disconnected.
|
|
func test_suspend_setsConnectionStateToDisconnected() async {
|
|
let conn = SessionConnection(id: "s1", credential: fakeCredential())
|
|
XCTAssertEqual(conn.connectionState, .disconnected,
|
|
"Fresh connection must start .disconnected")
|
|
await conn.suspend()
|
|
XCTAssertEqual(conn.connectionState, .disconnected,
|
|
"suspend() must keep/leave connectionState as .disconnected")
|
|
}
|
|
|
|
/// Calling suspend() twice must not crash or corrupt state.
|
|
func test_idempotentSuspend_doesNotCrash() async {
|
|
let conn = SessionConnection(id: "s2", credential: fakeCredential())
|
|
await conn.suspend()
|
|
await conn.suspend() // second call — must be a no-op
|
|
XCTAssertEqual(conn.connectionState, .disconnected,
|
|
"Double suspend must remain .disconnected")
|
|
}
|
|
|
|
/// resume(from:nil) must transition the connection to .connecting.
|
|
func test_resume_nil_transitionsToConnecting() async throws {
|
|
let conn = SessionConnection(id: "s3", credential: fakeCredential())
|
|
let exp = expectation(description: "connectionState reaches .connecting")
|
|
|
|
conn.$connectionState
|
|
.filter { $0 == .connecting }
|
|
.first()
|
|
.sink { _ in exp.fulfill() }
|
|
.store(in: &cancellables)
|
|
|
|
await conn.resume(from: nil)
|
|
await fulfillment(of: [exp], timeout: 2.0)
|
|
}
|
|
|
|
/// After an explicit suspend(), resume(from:lastSeq) must reconnect.
|
|
func test_resumeAfterSuspend_transitionsToConnecting() async throws {
|
|
let conn = SessionConnection(id: "s4", credential: fakeCredential())
|
|
await conn.suspend()
|
|
|
|
let exp = expectation(description: "resume after suspend reaches .connecting")
|
|
conn.$connectionState
|
|
.filter { $0 == .connecting }
|
|
.first()
|
|
.sink { _ in exp.fulfill() }
|
|
.store(in: &cancellables)
|
|
|
|
await conn.resume(from: 42)
|
|
await fulfillment(of: [exp], timeout: 2.0)
|
|
}
|
|
|
|
/// Calling resume() twice must not crash and must leave the connection in
|
|
/// a consistent (suspend-able) state. This is a regression guard — the
|
|
/// existing resume() implementation calls suspend() first so it is already
|
|
/// idempotent; this test should PASS and must not regress.
|
|
func test_idempotentResume_doesNotCrashOrOrphanPublishers() async throws {
|
|
let conn = SessionConnection(id: "s5", credential: fakeCredential())
|
|
|
|
// Count how many times state transitions to .connecting; a correct
|
|
// double-resume may produce at most 2 transitions (one per call).
|
|
var connectingCount = 0
|
|
conn.$connectionState
|
|
.filter { $0 == .connecting }
|
|
.sink { _ in connectingCount += 1 }
|
|
.store(in: &cancellables)
|
|
|
|
await conn.resume(from: nil)
|
|
await conn.resume(from: nil) // second call — must not crash
|
|
|
|
// Teardown must always succeed, regardless of double-resume.
|
|
await conn.suspend()
|
|
XCTAssertEqual(conn.connectionState, .disconnected,
|
|
"After suspend following double-resume, state must be .disconnected")
|
|
|
|
// At most 2 .connecting transitions (one per resume() call).
|
|
XCTAssertLessThanOrEqual(connectingCount, 2,
|
|
"Double resume must not produce more than 2 .connecting transitions")
|
|
}
|
|
|
|
// =========================================================================
|
|
// MARK: 2. Stale-frame freeze
|
|
//
|
|
// During the reconnect window (after resume(from: nonNil)), the stream
|
|
// publisher must NOT forward new frames to consumers until the first delta
|
|
// chunk arrives OR a snapshot completes. This prevents stale/mismatched
|
|
// content being fed to the terminal.
|
|
//
|
|
// FAILS until impl adds SessionConnection.isStreamFrozen.
|
|
// =========================================================================
|
|
|
|
/// After resume(from: nonNil), the stream must be frozen.
|
|
func test_staleFrameFreeze_isFrozenDuringReconnect() async throws {
|
|
let conn = SessionConnection(id: "freeze-1", credential: fakeCredential())
|
|
await conn.resume(from: 100) // non-nil seq → reconnect scenario
|
|
|
|
// Allow the async connect sequence to start
|
|
try await Task.sleep(nanoseconds: 50_000_000) // 50 ms
|
|
|
|
// FAILS: isStreamFrozen stub returns false; real impl must return true
|
|
// during the reconnect window.
|
|
XCTAssertTrue(conn.isStreamFrozen,
|
|
"T-2.10: Stream must be frozen while reconnecting (after resume with non-nil lastSeq). " +
|
|
"Impl agent: add SessionConnection.isStreamFrozen: Bool that returns true between " +
|
|
"resume(from: nonNil) and first delta/snapshot arrival.")
|
|
|
|
await conn.suspend()
|
|
}
|
|
|
|
/// After a fresh attach (resume(from: nil)), freeze should NOT be active —
|
|
/// the initial snapshot flow handles the cleared screen.
|
|
func test_staleFrameFreeze_notFrozenOnFreshAttach() async throws {
|
|
let conn = SessionConnection(id: "freeze-2", credential: fakeCredential())
|
|
await conn.resume(from: nil) // nil seq → fresh attach, no freeze needed
|
|
|
|
try await Task.sleep(nanoseconds: 50_000_000) // 50 ms
|
|
|
|
// The impl may set isStreamFrozen=false on fresh attach. The stub also
|
|
// returns false, so this assertion PASSES either way — it serves as a
|
|
// specification of intent and a future regression guard.
|
|
XCTAssertFalse(conn.isStreamFrozen,
|
|
"T-2.10: Fresh attach (lastSeq=nil) must NOT freeze the stream.")
|
|
|
|
await conn.suspend()
|
|
}
|
|
|
|
// =========================================================================
|
|
// MARK: 3. Keep-alive ping is foreground-only
|
|
//
|
|
// FAILS until impl adds SessionConnection.isKeepAliveActive.
|
|
// =========================================================================
|
|
|
|
/// After resume(), the keep-alive heartbeat must be scheduled (active).
|
|
func test_keepAlivePing_activeAfterResume() async throws {
|
|
let conn = SessionConnection(id: "ping-1", credential: fakeCredential())
|
|
await conn.resume(from: nil)
|
|
try await Task.sleep(nanoseconds: 50_000_000) // 50 ms
|
|
|
|
// FAILS: stub returns false; real impl must return true when connected/connecting.
|
|
XCTAssertTrue(conn.isKeepAliveActive,
|
|
"T-2.10: Keep-alive heartbeat must be active while the connection is open. " +
|
|
"Impl agent: add SessionConnection.isKeepAliveActive: Bool and schedule a " +
|
|
"repeating ping timer on resume().")
|
|
|
|
await conn.suspend()
|
|
}
|
|
|
|
/// After suspend(), the keep-alive heartbeat must be cancelled.
|
|
func test_keepAlivePing_inactiveAfterSuspend() async throws {
|
|
let conn = SessionConnection(id: "ping-2", credential: fakeCredential())
|
|
await conn.resume(from: nil)
|
|
try await Task.sleep(nanoseconds: 50_000_000) // 50 ms
|
|
|
|
// First check that it was active — this assertion will FAIL
|
|
// (see test_keepAlivePing_activeAfterResume). continueAfterFailure=true
|
|
// so we continue to the suspend check.
|
|
XCTAssertTrue(conn.isKeepAliveActive,
|
|
"T-2.10: Keep-alive must be active after resume() (precondition for this test).")
|
|
|
|
await conn.suspend()
|
|
|
|
XCTAssertFalse(conn.isKeepAliveActive,
|
|
"T-2.10: Keep-alive must be stopped after suspend().")
|
|
}
|
|
|
|
// =========================================================================
|
|
// MARK: 4. Reconnect latency P-3
|
|
//
|
|
// The suspend→resume roundtrip must have no artificial delays blocking the
|
|
// reconnect path. P-3 acceptance: <1 s on LAN. Here we check that the
|
|
// in-process part (excluding actual TCP round-trip) takes <200 ms.
|
|
// =========================================================================
|
|
|
|
func test_reconnectLatency_suspendResumeCycleUnder200ms() async throws {
|
|
let conn = SessionConnection(id: "latency-1", credential: fakeCredential())
|
|
await conn.resume(from: nil)
|
|
try await Task.sleep(nanoseconds: 50_000_000) // let .connecting settle
|
|
|
|
let start = Date()
|
|
await conn.suspend()
|
|
await conn.resume(from: 0)
|
|
let elapsed = Date().timeIntervalSince(start)
|
|
|
|
XCTAssertLessThan(elapsed, 0.2,
|
|
"T-2.10 P-3: suspend()+resume() in-process roundtrip must be <200 ms " +
|
|
"(i.e. no artificial sleep / synchronous blocking in the reconnect path)")
|
|
|
|
await conn.suspend()
|
|
}
|
|
}
|
|
|
|
// MARK: - Stubs removed
|
|
// isStreamFrozen and isKeepAliveActive are now implemented on SessionConnection
|
|
// directly (T-2.10 impl). The extension stubs above would cause a redeclaration
|
|
// error and are therefore removed.
|
|
|
|
// MARK: - CG-1: End-to-end real-seq path
|
|
|
|
@MainActor
|
|
final class EndToEndSeqPathTests: XCTestCase {
|
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
override func tearDown() async throws {
|
|
cancellables.removeAll()
|
|
}
|
|
|
|
/// CG-1: Verifies the full production path:
|
|
/// binary frame received
|
|
/// → ResumeCursor.update (B-1 fix)
|
|
/// → background (lastCapturedSeq = cursor.lastSeq)
|
|
/// → foreground resume(from: nonNil)
|
|
/// → isStreamFrozen = true
|
|
/// → first delta received → isStreamFrozen = false, forwarded to stream
|
|
///
|
|
/// Uses no hardcoded seq sentinels and exercises the real cursor update
|
|
/// path via `_testOnly_receiveBinaryFrame` (which calls `handleBinaryFrame`,
|
|
/// the same private method the production `incomingBinary` sink invokes).
|
|
func test_cg1_endToEnd_realSeqFlowsThrough() async throws {
|
|
// Isolated cursor so this test doesn't pollute real UserDefaults.
|
|
let suiteName = "CG1.\(UUID().uuidString)"
|
|
let defaults = UserDefaults(suiteName: suiteName)!
|
|
let cursor = ResumeCursor(defaults: defaults)
|
|
defer { defaults.removePersistentDomain(forName: suiteName) }
|
|
|
|
let conn = SessionConnection(
|
|
id: "cg1-session",
|
|
credential: fakeCredential(),
|
|
cursor: cursor
|
|
)
|
|
|
|
// ── Step 1: receive a binary frame with a real seq ────────────────
|
|
let seq1: UInt64 = 42
|
|
let frame1 = BinaryFrame(seq: seq1, data: Data("hello".utf8))
|
|
conn._testOnly_receiveBinaryFrame(frame1)
|
|
|
|
// ── Step 2: cursor is now updated (what MainTerminalView reads on bg)
|
|
let persistedSeq = cursor.lastSeq(for: "cg1-session")
|
|
XCTAssertEqual(persistedSeq, seq1,
|
|
"CG-1 / B-1: ResumeCursor must be updated with the frame's seq.")
|
|
|
|
// ── Step 3: simulate background — capture the cursor value ────────
|
|
let lastCapturedSeq = persistedSeq // non-nil because B-1 is fixed
|
|
XCTAssertNotNil(lastCapturedSeq,
|
|
"CG-1: lastCapturedSeq must be non-nil after a real binary frame")
|
|
|
|
// ── Step 4: foreground — resume from captured seq ─────────────────
|
|
await conn.resume(from: lastCapturedSeq)
|
|
try await Task.sleep(nanoseconds: 50_000_000) // 50 ms
|
|
|
|
XCTAssertTrue(conn.isStreamFrozen,
|
|
"CG-1: isStreamFrozen must be true immediately after resume(from: nonNil)")
|
|
|
|
// ── Step 5: first delta arrives → freeze clears, stream receives data
|
|
var receivedData: [Data] = []
|
|
let streamExp = expectation(description: "first delta forwarded to stream")
|
|
conn.stream
|
|
.first()
|
|
.sink { data in
|
|
receivedData.append(data)
|
|
streamExp.fulfill()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
let frame2 = BinaryFrame(seq: seq1 + 1, data: Data("world".utf8))
|
|
conn._testOnly_receiveBinaryFrame(frame2)
|
|
|
|
await fulfillment(of: [streamExp], timeout: 1.0)
|
|
|
|
XCTAssertFalse(conn.isStreamFrozen,
|
|
"CG-1: isStreamFrozen must clear on first binary frame (first delta IS forwarded)")
|
|
XCTAssertEqual(receivedData.count, 1,
|
|
"CG-1: Exactly one frame must reach stream")
|
|
XCTAssertEqual(receivedData.first, Data("world".utf8),
|
|
"CG-1: The delta payload must be forwarded unchanged")
|
|
XCTAssertEqual(cursor.lastSeq(for: "cg1-session"), seq1 + 1,
|
|
"CG-1: Cursor must be updated to the new seq after the first delta")
|
|
|
|
await conn.suspend()
|
|
}
|
|
}
|
|
|
|
// MARK: - CG-3: isStreamFrozen gating behaviour
|
|
|
|
@MainActor
|
|
final class StreamFrozenGatingTests: XCTestCase {
|
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
override func tearDown() async throws {
|
|
cancellables.removeAll()
|
|
}
|
|
|
|
/// CG-3 decision: INFORMATIONAL (not a hard gate on stream delivery).
|
|
///
|
|
/// In the IC-1 protocol the server only starts sending bytes after it
|
|
/// processes our `resume` frame, so there are no stale bytes that could
|
|
/// arrive while `isStreamFrozen == true`. The first binary frame IS
|
|
/// the first meaningful delta and IS forwarded to `stream`.
|
|
///
|
|
/// `isStreamFrozen` drives the status-bar label ("Reconnecting…" vs
|
|
/// "Connecting…") and exposes the freeze state for observability, but
|
|
/// does not technically block bytes from reaching `stream.send()`.
|
|
///
|
|
/// This test verifies:
|
|
/// - After resume(from: nonNil): isStreamFrozen = true
|
|
/// - After the first binary frame: isStreamFrozen = false
|
|
/// - The first binary frame IS forwarded to stream (first delta IS forwarded)
|
|
func test_cg3_firstDeltaThawsAndIsForwarded() async throws {
|
|
let conn = SessionConnection(id: "cg3", credential: fakeCredential())
|
|
|
|
await conn.resume(from: 50) // non-nil → freeze
|
|
try await Task.sleep(nanoseconds: 50_000_000)
|
|
XCTAssertTrue(conn.isStreamFrozen, "Must be frozen after resume(from: nonNil)")
|
|
|
|
var received: [Data] = []
|
|
conn.stream
|
|
.sink { received.append($0) }
|
|
.store(in: &cancellables)
|
|
|
|
// First delta arrives while frozen: must thaw AND be forwarded.
|
|
let firstDelta = BinaryFrame(seq: 51, data: Data("delta".utf8))
|
|
conn._testOnly_receiveBinaryFrame(firstDelta)
|
|
|
|
XCTAssertFalse(conn.isStreamFrozen,
|
|
"CG-3: First binary frame must clear isStreamFrozen")
|
|
XCTAssertEqual(received.count, 1,
|
|
"CG-3: First delta must be forwarded to stream (not dropped)")
|
|
|
|
// Subsequent frames also flow normally.
|
|
conn._testOnly_receiveBinaryFrame(BinaryFrame(seq: 52, data: Data("more".utf8)))
|
|
XCTAssertEqual(received.count, 2,
|
|
"CG-3: Subsequent frames must also be forwarded")
|
|
|
|
await conn.suspend()
|
|
}
|
|
|
|
/// Additional CG-3 guard: fresh attach (nil seq) must never set isStreamFrozen.
|
|
func test_cg3_freshAttach_neverFreezes() async throws {
|
|
let conn = SessionConnection(id: "cg3-fresh", credential: fakeCredential())
|
|
await conn.resume(from: nil) // nil → no freeze
|
|
try await Task.sleep(nanoseconds: 50_000_000)
|
|
XCTAssertFalse(conn.isStreamFrozen,
|
|
"CG-3: Fresh attach must not freeze stream")
|
|
await conn.suspend()
|
|
}
|
|
}
|
|
|
|
// MARK: - CG-4: Multi-cycle reconnect with seq progression
|
|
|
|
@MainActor
|
|
final class MultiCycleSeqTests: XCTestCase {
|
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
override func tearDown() async throws {
|
|
cancellables.removeAll()
|
|
}
|
|
|
|
/// CG-4: Two full suspend/resume cycles with seq advancing on each.
|
|
/// Cycle 1: receive seq=100 → suspend → resume(from:100) → frozen
|
|
/// Cycle 2: receive seq=120 → suspend → resume(from:120) → frozen
|
|
/// Verifies cursor advances and isStreamFrozen toggles correctly.
|
|
func test_cg4_multiCycle_seqProgression() async throws {
|
|
let suiteName = "CG4.\(UUID().uuidString)"
|
|
let defaults = UserDefaults(suiteName: suiteName)!
|
|
let cursor = ResumeCursor(defaults: defaults)
|
|
defer { defaults.removePersistentDomain(forName: suiteName) }
|
|
|
|
let conn = SessionConnection(
|
|
id: "cg4-session",
|
|
credential: fakeCredential(),
|
|
cursor: cursor
|
|
)
|
|
|
|
// ──────────────────────────────── Cycle 1 ────────────────────────────
|
|
// Receive a frame with seq=100.
|
|
conn._testOnly_receiveBinaryFrame(BinaryFrame(seq: 100, data: Data("a".utf8)))
|
|
XCTAssertEqual(cursor.lastSeq(for: "cg4-session"), 100,
|
|
"CG-4 cycle 1: cursor must be 100 after receiving seq=100")
|
|
|
|
// Suspend (isStreamFrozen cleared by suspend).
|
|
await conn.suspend()
|
|
XCTAssertFalse(conn.isStreamFrozen, "suspend() must clear isStreamFrozen")
|
|
|
|
// Resume from captured seq=100 → frozen.
|
|
await conn.resume(from: 100)
|
|
try await Task.sleep(nanoseconds: 50_000_000)
|
|
XCTAssertTrue(conn.isStreamFrozen,
|
|
"CG-4 cycle 1: must be frozen after resume(from: 100)")
|
|
|
|
// Receive seq=120 → thaws.
|
|
conn._testOnly_receiveBinaryFrame(BinaryFrame(seq: 120, data: Data("b".utf8)))
|
|
XCTAssertFalse(conn.isStreamFrozen,
|
|
"CG-4 cycle 1: first delta must clear freeze")
|
|
XCTAssertEqual(cursor.lastSeq(for: "cg4-session"), 120,
|
|
"CG-4 cycle 1: cursor must advance to 120")
|
|
|
|
// ──────────────────────────────── Cycle 2 ────────────────────────────
|
|
await conn.suspend()
|
|
XCTAssertFalse(conn.isStreamFrozen, "suspend() must clear isStreamFrozen")
|
|
|
|
// Resume from captured seq=120 → frozen.
|
|
await conn.resume(from: 120)
|
|
try await Task.sleep(nanoseconds: 50_000_000)
|
|
XCTAssertTrue(conn.isStreamFrozen,
|
|
"CG-4 cycle 2: must be frozen after resume(from: 120)")
|
|
|
|
// One more frame → thaws and cursor advances.
|
|
conn._testOnly_receiveBinaryFrame(BinaryFrame(seq: 150, data: Data("c".utf8)))
|
|
XCTAssertFalse(conn.isStreamFrozen,
|
|
"CG-4 cycle 2: second cycle delta must also clear freeze")
|
|
XCTAssertEqual(cursor.lastSeq(for: "cg4-session"), 150,
|
|
"CG-4 cycle 2: cursor must advance to 150")
|
|
|
|
await conn.suspend()
|
|
}
|
|
}
|