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