From 9fb5f813a15b82481d079de8baf0ec110984d045 Mon Sep 17 00:00:00 2001 From: jay Date: Fri, 15 May 2026 18:27:48 +0200 Subject: [PATCH] feat(T-2.1): WebSocketClient + FrameCodec + ResumeCursor --- Sources/Core/Network/FrameCodec.swift | 190 +++++++++++++++++ Sources/Core/Network/ResumeCursor.swift | 65 ++++++ Sources/Core/Network/WebSocketClient.swift | 199 ++++++++++++++++++ Tests/CoreTests/FrameCodecTests.swift | 229 +++++++++++++++++++++ 4 files changed, 683 insertions(+) create mode 100644 Sources/Core/Network/FrameCodec.swift create mode 100644 Sources/Core/Network/ResumeCursor.swift create mode 100644 Sources/Core/Network/WebSocketClient.swift create mode 100644 Tests/CoreTests/FrameCodecTests.swift diff --git a/Sources/Core/Network/FrameCodec.swift b/Sources/Core/Network/FrameCodec.swift new file mode 100644 index 0000000..fc444ba --- /dev/null +++ b/Sources/Core/Network/FrameCodec.swift @@ -0,0 +1,190 @@ +// FrameCodec.swift +// IC-1 WebSocket frame encoding/decoding. +// +// Wire format +// ----------- +// Binary frame (server → client): [seq: 8 bytes big-endian UInt64][raw ANSI bytes] +// Text frame (both directions) : JSON, type-discriminated by the "type" key. +// +// This file has no Starscream import — pure Foundation only, fully unit-testable. + +import Foundation + +// MARK: - PiState + +/// The reported state of the pi agent. +public enum PiState: String, Decodable, Sendable { + case thinking + case tool + case idle + case awaitingInput = "awaiting-input" +} + +// MARK: - ServerToClient + +/// JSON text frames sent from the server to the client. +public enum ServerToClient: Sendable { + /// Agent state update (thinking / tool / idle / awaiting-input). + case state(value: PiState, tool: String?, ts: Int) + /// Full-screen snapshot encoded as base64 ANSI bytes. + case snapshot(seq: UInt64, data: String) + /// Session metadata pushed on connect / rename. + case sessionMeta(name: String, description: String?, createdAt: String) + /// Protocol-level error from the server. + case error(code: String, message: String) +} + +extension ServerToClient: Decodable { + private enum TypeKey: String, Decodable { + case state + case snapshot + case sessionMeta = "session-meta" + case error + } + + private enum CodingKeys: String, CodingKey { + case type + case value, tool, ts // state + case seq, data // snapshot + case name, description, createdAt // session-meta + case code, message // error + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let typeKey = try container.decode(TypeKey.self, forKey: .type) + switch typeKey { + case .state: + let value = try container.decode(PiState.self, forKey: .value) + let tool = try container.decodeIfPresent(String.self, forKey: .tool) + let ts = try container.decode(Int.self, forKey: .ts) + self = .state(value: value, tool: tool, ts: ts) + + case .snapshot: + let seq = try container.decode(UInt64.self, forKey: .seq) + let data = try container.decode(String.self, forKey: .data) + self = .snapshot(seq: seq, data: data) + + case .sessionMeta: + let name = try container.decode(String.self, forKey: .name) + let description = try container.decodeIfPresent(String.self, forKey: .description) + let createdAt = try container.decode(String.self, forKey: .createdAt) + self = .sessionMeta(name: name, description: description, createdAt: createdAt) + + case .error: + let code = try container.decode(String.self, forKey: .code) + let message = try container.decode(String.self, forKey: .message) + self = .error(code: code, message: message) + } + } +} + +// MARK: - ClientToServer + +/// JSON text frames sent from the client to the server. +public enum ClientToServer: Sendable { + /// Attach / resume from the given sequence number (nil = start fresh). + case resume(lastSeq: UInt64?) + /// Named key press (e.g. "escape", "up", "enter"). + case key(name: String) + /// Literal text, delivered via send-keys -l. + case keys(data: String) + /// Text wrapped in bracketed-paste sequences. + case paste(data: String) + /// Request a full ANSI snapshot of the current pane. + case snapshotRequest +} + +extension ClientToServer: Encodable { + private enum CodingKeys: String, CodingKey { + case type, lastSeq, name, data + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .resume(let lastSeq): + try c.encode("resume", forKey: .type) + // Encode null explicitly when lastSeq is nil so the server + // receives {"type":"resume","lastSeq":null} as the spec requires. + try c.encode(lastSeq, forKey: .lastSeq) + + case .key(let name): + try c.encode("key", forKey: .type) + try c.encode(name, forKey: .name) + + case .keys(let data): + try c.encode("keys", forKey: .type) + try c.encode(data, forKey: .data) + + case .paste(let data): + try c.encode("paste", forKey: .type) + try c.encode(data, forKey: .data) + + case .snapshotRequest: + try c.encode("snapshot-request", forKey: .type) + } + } +} + +// MARK: - BinaryFrame + +/// A decoded IC-1 binary frame. +/// +/// Wire layout: `[seq: 8 bytes big-endian UInt64][raw ANSI bytes …]` +public struct BinaryFrame: Sendable { + /// Monotonically increasing chunk sequence number assigned by the server. + public let seq: UInt64 + /// Raw ANSI terminal bytes for this chunk. + public let data: Data + + /// Decodes a raw WebSocket binary message into a `BinaryFrame`. + /// + /// Returns `nil` when `raw` is shorter than the 8-byte header. + public static func decode(_ raw: Data) -> BinaryFrame? { + guard raw.count >= 8 else { return nil } + + // Read 8-byte big-endian UInt64 from the leading bytes. + let seqBytes = raw.prefix(8) + let seq = seqBytes.reduce(into: UInt64(0)) { acc, byte in + acc = (acc << 8) | UInt64(byte) + } + + let payload = raw.dropFirst(8) + return BinaryFrame(seq: seq, data: Data(payload)) + } +} + +// MARK: - FrameCodec (static utilities) + +/// Namespace for IC-1 frame encode/decode helpers. +public enum FrameCodec { + + // Shared encoder / decoder — both are safe to use from a single actor. + private static let encoder: JSONEncoder = { + let enc = JSONEncoder() + enc.outputFormatting = [] // compact, stable output + return enc + }() + + private static let decoder = JSONDecoder() + + /// Encodes a `ClientToServer` frame to a compact JSON string. + /// + /// Throws if encoding fails (in practice this should never happen for + /// the well-typed cases defined above). + public static func encode(_ frame: ClientToServer) throws -> String { + let data = try encoder.encode(frame) + // JSON serialisation always produces valid UTF-8; the force-unwrap + // is safe and intentional — a nil result here would be a Foundation bug. + return String(data: data, encoding: .utf8)! // swiftlint:disable:this force_unwrap + } + + /// Decodes a JSON string into a `ServerToClient` frame. + /// + /// Throws `DecodingError` for malformed or unknown payloads. + public static func decode(_ text: String) throws -> ServerToClient { + let data = Data(text.utf8) + return try decoder.decode(ServerToClient.self, from: data) + } +} diff --git a/Sources/Core/Network/ResumeCursor.swift b/Sources/Core/Network/ResumeCursor.swift new file mode 100644 index 0000000..fb9472c --- /dev/null +++ b/Sources/Core/Network/ResumeCursor.swift @@ -0,0 +1,65 @@ +// ResumeCursor.swift +// Persists the last-seen IC-1 sequence number per session across app launches. +// +// Storage: UserDefaults (standard suite). +// Key schema: "ResumeCursor." +// +// Thread-safety: UserDefaults is documented as thread-safe; this class adds no +// additional synchronisation. All callers are expected to be on the main actor +// in practice (alongside WebSocketClient), though no actor annotation is +// imposed here so tests can call freely. + +import Foundation + +/// Stores and retrieves the last acknowledged IC-1 sequence number for each +/// session, enabling resume-from-cursor on reconnect. +public final class ResumeCursor { + + private let defaults: UserDefaults + private let keyPrefix = "ResumeCursor." + + // MARK: - Init + + /// Creates a cursor backed by `defaults` (defaults to `.standard`). + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + // MARK: - Public API + + /// Returns the last persisted sequence number for `sessionId`, or `nil` + /// if no sequence has been stored (i.e. first-ever connection). + public func lastSeq(for sessionId: String) -> UInt64? { + let key = storageKey(for: sessionId) + // UserDefaults stores integer values as Int64 (signed). We use the raw + // bit pattern to round-trip UInt64 without loss, since UInt64.max + // exceeds Int.max on 64-bit platforms. + guard defaults.object(forKey: key) != nil else { return nil } + let raw = defaults.integer(forKey: key) + return UInt64(bitPattern: Int64(raw)) + } + + /// Persists `seq` as the latest acknowledged chunk for `sessionId`. + /// + /// Called after successfully processing a `BinaryFrame` so that the cursor + /// always reflects a frame that the app has actually consumed. + public func update(sessionId: String, seq: UInt64) { + // Store as Int64 bit-pattern; see note in lastSeq(for:). + let raw = Int(Int64(bitPattern: seq)) + defaults.set(raw, forKey: storageKey(for: sessionId)) + } + + /// Removes the stored cursor for `sessionId`. + /// + /// Call this when a session is deleted or a full snapshot has been received + /// and the client no longer needs delta replay. + public func clear(sessionId: String) { + defaults.removeObject(forKey: storageKey(for: sessionId)) + } + + // MARK: - Private + + private func storageKey(for sessionId: String) -> String { + keyPrefix + sessionId + } +} diff --git a/Sources/Core/Network/WebSocketClient.swift b/Sources/Core/Network/WebSocketClient.swift new file mode 100644 index 0000000..6d8e519 --- /dev/null +++ b/Sources/Core/Network/WebSocketClient.swift @@ -0,0 +1,199 @@ +// WebSocketClient.swift +// Starscream wrapper that speaks the IC-1 WebSocket protocol. +// +// URL pattern: ws(s):///sessions//stream?token= +// +// Design note: Starscream 4.0.x also declares a *protocol* named +// `WebSocketClient` (the abstract socket interface). Our concrete class +// shares that name. Inside this file we qualify Starscream's protocol as +// `Starscream.WebSocketClient` to resolve the ambiguity; everywhere else in +// the app our class is the only visible `WebSocketClient`. + +import Combine +import Foundation +import Starscream + +// MARK: - ConnectionState + +/// The lifecycle state of a `WebSocketClient` connection. +public enum ConnectionState: Sendable { + case disconnected + case connecting + case connected +} + +// MARK: - WebSocketClientError + +/// Errors thrown by `WebSocketClient.send(_:)`. +public enum WebSocketClientError: Error, Sendable { + /// The socket is not in the `.connected` state. + case notConnected + /// JSON encoding of the outgoing frame failed. + case encodingFailed(any Error) +} + +// MARK: - WebSocketClient + +/// IC-1 WebSocket client. +/// +/// All public state is isolated to the main actor. +/// Starscream's delegate callbacks (delivered on `DispatchQueue.main` by +/// default) are hopped back through a `Task { @MainActor in … }` in +/// `DelegateAdapter` before any mutation occurs, keeping the actor +/// boundary explicit. +@MainActor +public final class WebSocketClient { + + // MARK: - Published subjects + + /// Emits every successfully decoded binary frame received from the server. + public let incomingBinary = PassthroughSubject() + + /// Emits every successfully decoded JSON frame received from the server. + public let incomingJSON = PassthroughSubject() + + /// Tracks the current lifecycle state. + public let connectionState = CurrentValueSubject(.disconnected) + + // MARK: - Private state + + /// The active Starscream socket. `WebSocket` is the concrete Starscream + /// class; no name collision here. + private var socket: WebSocket? + + /// Non-isolated trampoline that holds a weak back-reference to us. + private let delegateAdapter = DelegateAdapter() + + // MARK: - Init + + public init() { + delegateAdapter.owner = self + } + + // MARK: - Connect / Disconnect + + /// Opens a new WebSocket connection to `url`, tearing down any existing + /// socket first. + /// + /// After the `connected` state is published, call + /// `send(.resume(lastSeq:))` to attach the IC-1 stream. + public func connect(url: URL) { + socket?.disconnect() + socket = nil + + var request = URLRequest(url: url) + request.timeoutInterval = 10 + + let ws = WebSocket(request: request) + // Starscream delivers delegate calls on DispatchQueue.main by default. + ws.delegate = delegateAdapter + socket = ws + + connectionState.send(.connecting) + ws.connect() + } + + /// Gracefully closes the current socket. + public func disconnect() { + socket?.disconnect() + socket = nil + connectionState.send(.disconnected) + } + + // MARK: - Send + + /// Encodes `frame` as JSON and writes it to the open socket. + /// + /// - Throws: `WebSocketClientError.notConnected` when not connected, + /// `WebSocketClientError.encodingFailed` on JSON encoding error. + public func send(_ frame: ClientToServer) async throws { + guard let socket, connectionState.value == .connected else { + throw WebSocketClientError.notConnected + } + let json: String + do { + json = try FrameCodec.encode(frame) + } catch { + throw WebSocketClientError.encodingFailed(error) + } + socket.write(string: json) + } + + // MARK: - Internal event handling (always on main actor) + + fileprivate func handle(event: WebSocketEvent) { + switch event { + + case .connected: + connectionState.send(.connected) + + case .disconnected(let reason, let code): + // Both clean server-initiated and transport-level disconnects + // collapse to the same client state. + _ = (reason, code) + connectionState.send(.disconnected) + + case .text(let string): + do { + let frame = try FrameCodec.decode(string) + incomingJSON.send(frame) + } catch { + #if DEBUG + print("[WebSocketClient] JSON decode error: \(error)\nPayload: \(string)") + #endif + } + + case .binary(let data): + if let frame = BinaryFrame.decode(data) { + incomingBinary.send(frame) + } else { + #if DEBUG + print("[WebSocketClient] Binary frame too short (\(data.count) bytes) — ignored.") + #endif + } + + case .cancelled: + connectionState.send(.disconnected) + + case .error(let error): + #if DEBUG + let desc = error.map { "\($0)" } ?? "unknown" + print("[WebSocketClient] Socket error: \(desc)") + #endif + connectionState.send(.disconnected) + + case .peerClosed: + connectionState.send(.disconnected) + + case .ping, .pong, .viabilityChanged, .reconnectSuggested: + break + } + } +} + +// MARK: - DelegateAdapter + +/// Non-isolated trampoline between Starscream's `WebSocketDelegate` callbacks +/// and our `@MainActor`-isolated `WebSocketClient`. +/// +/// **Why a separate class?** +/// `WebSocketDelegate` is not `@MainActor`, so Swift 6 strict-concurrency +/// disallows directly satisfying it from a `@MainActor final class` without +/// `nonisolated`. A nested non-isolated class that hops explicitly via +/// `Task { @MainActor in … }` keeps the intent clear and compiler-clean. +/// +/// **Naming note:** Starscream 4.0.x declares `WebSocketClient` as a *protocol* +/// (the abstract socket interface). The `didReceive` method's `client` parameter +/// therefore has type `any Starscream.WebSocketClient`, qualified to disambiguate +/// from our own concrete `WebSocketClient` class. +private final class DelegateAdapter: WebSocketDelegate, @unchecked Sendable { + + weak var owner: WebSocketClient? + + func didReceive(event: WebSocketEvent, client: any Starscream.WebSocketClient) { + guard let owner else { return } + Task { @MainActor [owner] in + owner.handle(event: event) + } + } +} diff --git a/Tests/CoreTests/FrameCodecTests.swift b/Tests/CoreTests/FrameCodecTests.swift new file mode 100644 index 0000000..c11a121 --- /dev/null +++ b/Tests/CoreTests/FrameCodecTests.swift @@ -0,0 +1,229 @@ +// FrameCodecTests.swift +// Unit tests for BinaryFrame, ClientToServer encoding, and ServerToClient decoding. +// +// All tests are pure (no network, no Starscream, no async) — FrameCodec.swift +// imports only Foundation, making this test target dependency-free. + +import XCTest +@testable import piRemote + +final class FrameCodecTests: XCTestCase { + + // ------------------------------------------------------------------------- + // MARK: 1. BinaryFrame.decode + // ------------------------------------------------------------------------- + + /// A well-formed binary frame: 8-byte big-endian seq = 1, payload = "hello". + func testBinaryFrameDecode_knownBytes() throws { + // seq = 1 in big-endian: 00 00 00 00 00 00 00 01 + let seqBytes: [UInt8] = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01] + let payload = Array("hello".utf8) + let raw = Data(seqBytes + payload) + + let frame = try XCTUnwrap(BinaryFrame.decode(raw)) + XCTAssertEqual(frame.seq, 1) + XCTAssertEqual(frame.data, Data("hello".utf8)) + } + + /// Large sequence numbers (UInt64.max) must round-trip through the header. + func testBinaryFrameDecode_maxSeq() throws { + // seq = UInt64.max = FF FF FF FF FF FF FF FF + let seqBytes: [UInt8] = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF] + let raw = Data(seqBytes) // empty payload is valid + let frame = try XCTUnwrap(BinaryFrame.decode(raw)) + XCTAssertEqual(frame.seq, UInt64.max) + XCTAssertTrue(frame.data.isEmpty) + } + + /// A multi-byte big-endian seq: 0x0000_0001_0000_0000 = 4_294_967_296 + func testBinaryFrameDecode_bigEndianOrdering() throws { + let seqBytes: [UInt8] = [0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00] + let raw = Data(seqBytes + [0xAB, 0xCD]) + let frame = try XCTUnwrap(BinaryFrame.decode(raw)) + XCTAssertEqual(frame.seq, 4_294_967_296) + XCTAssertEqual(frame.data, Data([0xAB, 0xCD])) + } + + /// Frames shorter than 8 bytes must be rejected (returns nil). + func testBinaryFrameDecode_tooShort_returnsNil() { + let raw = Data([0x00, 0x01, 0x02]) + XCTAssertNil(BinaryFrame.decode(raw)) + } + + /// Exactly 8 bytes is valid (empty payload, seq depends on content). + func testBinaryFrameDecode_exactlyEightBytes_emptyPayload() throws { + let seqBytes: [UInt8] = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2A] // seq = 42 + let frame = try XCTUnwrap(BinaryFrame.decode(Data(seqBytes))) + XCTAssertEqual(frame.seq, 42) + XCTAssertTrue(frame.data.isEmpty) + } + + // ------------------------------------------------------------------------- + // MARK: 2. ClientToServer JSON encoding + // ------------------------------------------------------------------------- + + func testEncode_resume_withLastSeq() throws { + let json = try FrameCodec.encode(.resume(lastSeq: 99)) + let obj = try JSONSerialization.jsonObject(with: Data(json.utf8)) as! [String: Any] + XCTAssertEqual(obj["type"] as? String, "resume") + XCTAssertEqual(obj["lastSeq"] as? Int, 99) + } + + func testEncode_resume_nilLastSeq_encodesNull() throws { + let json = try FrameCodec.encode(.resume(lastSeq: nil)) + // Verify "lastSeq" key exists and its value is JSON null. + let obj = try JSONSerialization.jsonObject(with: Data(json.utf8)) as! [String: Any] + XCTAssertEqual(obj["type"] as? String, "resume") + // JSON null comes back as NSNull in Foundation. + XCTAssertTrue(obj["lastSeq"] is NSNull, "Expected lastSeq to be null, got \(String(describing: obj["lastSeq"]))") + } + + func testEncode_key() throws { + let json = try FrameCodec.encode(.key(name: "escape")) + XCTAssertEqual(json, #"{"type":"key","name":"escape"}"#) + } + + func testEncode_keys() throws { + let json = try FrameCodec.encode(.keys(data: "hello world")) + let obj = try JSONSerialization.jsonObject(with: Data(json.utf8)) as! [String: Any] + XCTAssertEqual(obj["type"] as? String, "keys") + XCTAssertEqual(obj["data"] as? String, "hello world") + } + + func testEncode_paste() throws { + let json = try FrameCodec.encode(.paste(data: "pasted text\nwith newline")) + let obj = try JSONSerialization.jsonObject(with: Data(json.utf8)) as! [String: Any] + XCTAssertEqual(obj["type"] as? String, "paste") + XCTAssertEqual(obj["data"] as? String, "pasted text\nwith newline") + } + + func testEncode_snapshotRequest() throws { + let json = try FrameCodec.encode(.snapshotRequest) + XCTAssertEqual(json, #"{"type":"snapshot-request"}"#) + } + + // ------------------------------------------------------------------------- + // MARK: 3. ServerToClient JSON decoding + // ------------------------------------------------------------------------- + + func testDecode_state_idle() throws { + let payload = #"{"type":"state","value":"idle","ts":1716000000}"# + let frame = try FrameCodec.decode(payload) + guard case .state(let value, let tool, let ts) = frame else { + return XCTFail("Expected .state, got \(frame)") + } + XCTAssertEqual(value, .idle) + XCTAssertNil(tool) + XCTAssertEqual(ts, 1_716_000_000) + } + + func testDecode_state_tool_withToolName() throws { + let payload = #"{"type":"state","value":"tool","tool":"bash","ts":42}"# + let frame = try FrameCodec.decode(payload) + guard case .state(let value, let tool, let ts) = frame else { + return XCTFail("Expected .state, got \(frame)") + } + XCTAssertEqual(value, .tool) + XCTAssertEqual(tool, "bash") + XCTAssertEqual(ts, 42) + } + + func testDecode_state_awaitingInput() throws { + let payload = #"{"type":"state","value":"awaiting-input","ts":0}"# + let frame = try FrameCodec.decode(payload) + guard case .state(let value, _, _) = frame else { + return XCTFail("Expected .state, got \(frame)") + } + XCTAssertEqual(value, .awaitingInput) + } + + func testDecode_snapshot() throws { + let payload = #"{"type":"snapshot","seq":1234,"data":"SGVsbG8="}"# + let frame = try FrameCodec.decode(payload) + guard case .snapshot(let seq, let data) = frame else { + return XCTFail("Expected .snapshot, got \(frame)") + } + XCTAssertEqual(seq, 1234) + XCTAssertEqual(data, "SGVsbG8=") + } + + func testDecode_sessionMeta_withDescription() throws { + let payload = #"{"type":"session-meta","name":"my-session","description":"A test session","createdAt":"2026-05-15T10:00:00Z"}"# + let frame = try FrameCodec.decode(payload) + guard case .sessionMeta(let name, let description, let createdAt) = frame else { + return XCTFail("Expected .sessionMeta, got \(frame)") + } + XCTAssertEqual(name, "my-session") + XCTAssertEqual(description, "A test session") + XCTAssertEqual(createdAt, "2026-05-15T10:00:00Z") + } + + func testDecode_sessionMeta_withoutDescription() throws { + let payload = #"{"type":"session-meta","name":"bare","createdAt":"2026-01-01T00:00:00Z"}"# + let frame = try FrameCodec.decode(payload) + guard case .sessionMeta(let name, let description, _) = frame else { + return XCTFail("Expected .sessionMeta, got \(frame)") + } + XCTAssertEqual(name, "bare") + XCTAssertNil(description) + } + + func testDecode_error() throws { + let payload = #"{"type":"error","code":"auth_failed","message":"Invalid token"}"# + let frame = try FrameCodec.decode(payload) + guard case .error(let code, let message) = frame else { + return XCTFail("Expected .error, got \(frame)") + } + XCTAssertEqual(code, "auth_failed") + XCTAssertEqual(message, "Invalid token") + } + + /// Unknown type keys must throw rather than silently produce garbage. + func testDecode_unknownType_throws() { + let payload = #"{"type":"tree","nodes":[]}"# + XCTAssertThrowsError(try FrameCodec.decode(payload)) + } + + // ------------------------------------------------------------------------- + // MARK: 4. ResumeCursor (bonus — no I/O, uses in-memory UserDefaults suite) + // ------------------------------------------------------------------------- + + func testResumeCursor_roundTrip() { + let suiteName = "FrameCodecTests.\(UUID())" + // Using a named suite isolates this test from real app defaults. + let defaults = UserDefaults(suiteName: suiteName)! + let cursor = ResumeCursor(defaults: defaults) + + XCTAssertNil(cursor.lastSeq(for: "s1"), "Fresh cursor should be nil") + + cursor.update(sessionId: "s1", seq: 999) + XCTAssertEqual(cursor.lastSeq(for: "s1"), 999) + + cursor.update(sessionId: "s1", seq: UInt64.max) + XCTAssertEqual(cursor.lastSeq(for: "s1"), UInt64.max) + + cursor.clear(sessionId: "s1") + XCTAssertNil(cursor.lastSeq(for: "s1"), "Cursor should be nil after clear") + + // Cleanup: remove the test suite. + defaults.removePersistentDomain(forName: suiteName) + } + + func testResumeCursor_isolatedPerSession() { + let suiteName = "FrameCodecTests.\(UUID())" + let defaults = UserDefaults(suiteName: suiteName)! + let cursor = ResumeCursor(defaults: defaults) + + cursor.update(sessionId: "alpha", seq: 10) + cursor.update(sessionId: "beta", seq: 20) + + XCTAssertEqual(cursor.lastSeq(for: "alpha"), 10) + XCTAssertEqual(cursor.lastSeq(for: "beta"), 20) + + cursor.clear(sessionId: "alpha") + XCTAssertNil(cursor.lastSeq(for: "alpha")) + XCTAssertEqual(cursor.lastSeq(for: "beta"), 20, "Clearing alpha must not affect beta") + + defaults.removePersistentDomain(forName: suiteName) + } +}