merge: T-2.1 WebSocketClient + FrameCodec + ResumeCursor
This commit is contained in:
commit
d6062000e8
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.<sessionId>"
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
// WebSocketClient.swift
|
||||
// Starscream wrapper that speaks the IC-1 WebSocket protocol.
|
||||
//
|
||||
// URL pattern: ws(s)://<host>/sessions/<id>/stream?token=<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<BinaryFrame, Never>()
|
||||
|
||||
/// Emits every successfully decoded JSON frame received from the server.
|
||||
public let incomingJSON = PassthroughSubject<ServerToClient, Never>()
|
||||
|
||||
/// Tracks the current lifecycle state.
|
||||
public let connectionState = CurrentValueSubject<ConnectionState, Never>(.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue