// 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() 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() 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() 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() 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() } }