fix(mac): harden gateway frame decoding
This commit is contained in:
@@ -7,8 +7,8 @@ enum InstanceIdentity {
|
|||||||
private static var defaults: UserDefaults {
|
private static var defaults: UserDefaults {
|
||||||
UserDefaults(suiteName: suiteName) ?? .standard
|
UserDefaults(suiteName: suiteName) ?? .standard
|
||||||
}
|
}
|
||||||
|
|
||||||
static let instanceId: String = {
|
static let instanceId: String = {
|
||||||
|
let defaults = Self.defaults
|
||||||
if let existing = defaults.string(forKey: instanceIdKey)?
|
if let existing = defaults.string(forKey: instanceIdKey)?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!existing.isEmpty
|
!existing.isEmpty
|
||||||
|
|||||||
@@ -303,8 +303,12 @@ enum CommandResolver {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
static func clawdisNodeCommand(subcommand: String, extraArgs: [String] = []) -> [String] {
|
static func clawdisNodeCommand(
|
||||||
let settings = self.connectionSettings()
|
subcommand: String,
|
||||||
|
extraArgs: [String] = [],
|
||||||
|
defaults: UserDefaults = .standard) -> [String]
|
||||||
|
{
|
||||||
|
let settings = self.connectionSettings(defaults: defaults)
|
||||||
if settings.mode == .remote, let ssh = self.sshNodeCommand(
|
if settings.mode == .remote, let ssh = self.sshNodeCommand(
|
||||||
subcommand: subcommand,
|
subcommand: subcommand,
|
||||||
extraArgs: extraArgs,
|
extraArgs: extraArgs,
|
||||||
@@ -343,8 +347,12 @@ enum CommandResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func clawdisMacCommand(subcommand: String, extraArgs: [String] = []) -> [String] {
|
static func clawdisMacCommand(
|
||||||
let settings = self.connectionSettings()
|
subcommand: String,
|
||||||
|
extraArgs: [String] = [],
|
||||||
|
defaults: UserDefaults = .standard) -> [String]
|
||||||
|
{
|
||||||
|
let settings = self.connectionSettings(defaults: defaults)
|
||||||
if settings.mode == .remote, let ssh = self.sshMacHelperCommand(
|
if settings.mode == .remote, let ssh = self.sshMacHelperCommand(
|
||||||
subcommand: subcommand,
|
subcommand: subcommand,
|
||||||
extraArgs: extraArgs,
|
extraArgs: extraArgs,
|
||||||
@@ -359,8 +367,12 @@ enum CommandResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Existing callers still refer to clawdisCommand; keep it as node alias.
|
// Existing callers still refer to clawdisCommand; keep it as node alias.
|
||||||
static func clawdisCommand(subcommand: String, extraArgs: [String] = []) -> [String] {
|
static func clawdisCommand(
|
||||||
self.clawdisNodeCommand(subcommand: subcommand, extraArgs: extraArgs)
|
subcommand: String,
|
||||||
|
extraArgs: [String] = [],
|
||||||
|
defaults: UserDefaults = .standard) -> [String]
|
||||||
|
{
|
||||||
|
self.clawdisNodeCommand(subcommand: subcommand, extraArgs: extraArgs, defaults: defaults)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SSH helpers
|
// MARK: - SSH helpers
|
||||||
@@ -477,12 +489,12 @@ enum CommandResolver {
|
|||||||
let projectRoot: String
|
let projectRoot: String
|
||||||
}
|
}
|
||||||
|
|
||||||
static func connectionSettings() -> RemoteSettings {
|
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
|
||||||
let modeRaw = UserDefaults.standard.string(forKey: connectionModeKey) ?? "local"
|
let modeRaw = defaults.string(forKey: connectionModeKey) ?? "local"
|
||||||
let mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
|
let mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
|
||||||
let target = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
let target = defaults.string(forKey: remoteTargetKey) ?? ""
|
||||||
let identity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
|
||||||
let projectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? ""
|
||||||
return RemoteSettings(
|
return RemoteSettings(
|
||||||
mode: mode,
|
mode: mode,
|
||||||
target: self.sanitizedTarget(target),
|
target: self.sanitizedTarget(target),
|
||||||
@@ -494,8 +506,8 @@ enum CommandResolver {
|
|||||||
UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
|
UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func connectionModeIsRemote() -> Bool {
|
static func connectionModeIsRemote(defaults: UserDefaults = .standard) -> Bool {
|
||||||
self.connectionSettings().mode == .remote
|
self.connectionSettings(defaults: defaults).mode == .remote
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func sanitizedTarget(_ raw: String) -> String {
|
private static func sanitizedTarget(_ raw: String) -> String {
|
||||||
|
|||||||
@@ -545,26 +545,29 @@ public enum GatewayFrame: Codable {
|
|||||||
case event(EventFrame)
|
case event(EventFrame)
|
||||||
case unknown(type: String, raw: [String: AnyCodable])
|
case unknown(type: String, raw: [String: AnyCodable])
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case type
|
||||||
|
}
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.singleValueContainer()
|
let typeContainer = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
let raw = try container.decode([String: AnyCodable].self)
|
let type = try typeContainer.decode(String.self, forKey: .type)
|
||||||
guard let type = raw["type"]?.value as? String else {
|
|
||||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "missing type")
|
|
||||||
}
|
|
||||||
switch type {
|
switch type {
|
||||||
case "hello":
|
case "hello":
|
||||||
self = .hello(try Self.decodePayload(Hello.self, from: raw))
|
self = .hello(try Hello(from: decoder))
|
||||||
case "hello-ok":
|
case "hello-ok":
|
||||||
self = .helloOk(try Self.decodePayload(HelloOk.self, from: raw))
|
self = .helloOk(try HelloOk(from: decoder))
|
||||||
case "hello-error":
|
case "hello-error":
|
||||||
self = .helloError(try Self.decodePayload(HelloError.self, from: raw))
|
self = .helloError(try HelloError(from: decoder))
|
||||||
case "req":
|
case "req":
|
||||||
self = .req(try Self.decodePayload(RequestFrame.self, from: raw))
|
self = .req(try RequestFrame(from: decoder))
|
||||||
case "res":
|
case "res":
|
||||||
self = .res(try Self.decodePayload(ResponseFrame.self, from: raw))
|
self = .res(try ResponseFrame(from: decoder))
|
||||||
case "event":
|
case "event":
|
||||||
self = .event(try Self.decodePayload(EventFrame.self, from: raw))
|
self = .event(try EventFrame(from: decoder))
|
||||||
default:
|
default:
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
let raw = try container.decode([String: AnyCodable].self)
|
||||||
self = .unknown(type: type, raw: raw)
|
self = .unknown(type: type, raw: raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,13 +586,4 @@ public enum GatewayFrame: Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private static func decodePayload<T: Decodable>(_ type: T.Type, from raw: [String: AnyCodable]) throws -> T {
|
|
||||||
// raw is [String: AnyCodable] which is not directly JSONSerialization-compatible.
|
|
||||||
// Round-trip through JSONEncoder so AnyCodable can encode itself safely.
|
|
||||||
let data = try JSONEncoder().encode(raw)
|
|
||||||
let decoder = JSONDecoder()
|
|
||||||
return try decoder.decode(T.self, from: data)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import Testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func rejectEmptyMessage() async {
|
@Test func rejectEmptyMessage() async {
|
||||||
let result = await AgentRPC.shared.send(text: "", thinking: nil, session: "main", deliver: false, to: nil)
|
let result = await AgentRPC.shared.send(text: "", thinking: nil, sessionKey: "main", deliver: false, to: nil)
|
||||||
#expect(result.ok == false)
|
#expect(result.ok == false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import Testing
|
|||||||
@testable import Clawdis
|
@testable import Clawdis
|
||||||
|
|
||||||
@Suite(.serialized) struct CommandResolverTests {
|
@Suite(.serialized) struct CommandResolverTests {
|
||||||
|
private func makeDefaults() -> UserDefaults {
|
||||||
|
// Use a unique suite to avoid cross-suite concurrency on UserDefaults.standard.
|
||||||
|
UserDefaults(suiteName: "CommandResolverTests.\(UUID().uuidString)")!
|
||||||
|
}
|
||||||
|
|
||||||
private func makeTempDir() throws -> URL {
|
private func makeTempDir() throws -> URL {
|
||||||
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||||
let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
@@ -20,16 +25,8 @@ import Testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func prefersClawdisBinary() async throws {
|
@Test func prefersClawdisBinary() async throws {
|
||||||
UserDefaults.standard.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
|
let defaults = self.makeDefaults()
|
||||||
UserDefaults.standard.removeObject(forKey: remoteTargetKey)
|
defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
|
||||||
UserDefaults.standard.removeObject(forKey: remoteIdentityKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteProjectRootKey)
|
|
||||||
defer {
|
|
||||||
UserDefaults.standard.removeObject(forKey: connectionModeKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteTargetKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteIdentityKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteProjectRootKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
let tmp = try makeTempDir()
|
let tmp = try makeTempDir()
|
||||||
CommandResolver.setProjectRoot(tmp.path)
|
CommandResolver.setProjectRoot(tmp.path)
|
||||||
@@ -37,21 +34,13 @@ import Testing
|
|||||||
let clawdisPath = tmp.appendingPathComponent("node_modules/.bin/clawdis")
|
let clawdisPath = tmp.appendingPathComponent("node_modules/.bin/clawdis")
|
||||||
try self.makeExec(at: clawdisPath)
|
try self.makeExec(at: clawdisPath)
|
||||||
|
|
||||||
let cmd = CommandResolver.clawdisCommand(subcommand: "gateway")
|
let cmd = CommandResolver.clawdisCommand(subcommand: "gateway", defaults: defaults)
|
||||||
#expect(cmd.prefix(2).elementsEqual([clawdisPath.path, "gateway"]))
|
#expect(cmd.prefix(2).elementsEqual([clawdisPath.path, "gateway"]))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func fallsBackToNodeAndScript() async throws {
|
@Test func fallsBackToNodeAndScript() async throws {
|
||||||
UserDefaults.standard.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
|
let defaults = self.makeDefaults()
|
||||||
UserDefaults.standard.removeObject(forKey: remoteTargetKey)
|
defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
|
||||||
UserDefaults.standard.removeObject(forKey: remoteIdentityKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteProjectRootKey)
|
|
||||||
defer {
|
|
||||||
UserDefaults.standard.removeObject(forKey: connectionModeKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteTargetKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteIdentityKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteProjectRootKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
let tmp = try makeTempDir()
|
let tmp = try makeTempDir()
|
||||||
CommandResolver.setProjectRoot(tmp.path)
|
CommandResolver.setProjectRoot(tmp.path)
|
||||||
@@ -63,7 +52,7 @@ import Testing
|
|||||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
|
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
|
||||||
try self.makeExec(at: scriptPath)
|
try self.makeExec(at: scriptPath)
|
||||||
|
|
||||||
let cmd = CommandResolver.clawdisCommand(subcommand: "rpc")
|
let cmd = CommandResolver.clawdisCommand(subcommand: "rpc", defaults: defaults)
|
||||||
|
|
||||||
#expect(cmd.count >= 3)
|
#expect(cmd.count >= 3)
|
||||||
#expect(cmd[0] == nodePath.path)
|
#expect(cmd[0] == nodePath.path)
|
||||||
@@ -72,16 +61,8 @@ import Testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func fallsBackToPnpm() async throws {
|
@Test func fallsBackToPnpm() async throws {
|
||||||
UserDefaults.standard.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
|
let defaults = self.makeDefaults()
|
||||||
UserDefaults.standard.removeObject(forKey: remoteTargetKey)
|
defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
|
||||||
UserDefaults.standard.removeObject(forKey: remoteIdentityKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteProjectRootKey)
|
|
||||||
defer {
|
|
||||||
UserDefaults.standard.removeObject(forKey: connectionModeKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteTargetKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteIdentityKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteProjectRootKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
let tmp = try makeTempDir()
|
let tmp = try makeTempDir()
|
||||||
CommandResolver.setProjectRoot(tmp.path)
|
CommandResolver.setProjectRoot(tmp.path)
|
||||||
@@ -89,22 +70,14 @@ import Testing
|
|||||||
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
|
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
|
||||||
try self.makeExec(at: pnpmPath)
|
try self.makeExec(at: pnpmPath)
|
||||||
|
|
||||||
let cmd = CommandResolver.clawdisCommand(subcommand: "rpc")
|
let cmd = CommandResolver.clawdisCommand(subcommand: "rpc", defaults: defaults)
|
||||||
|
|
||||||
#expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "clawdis", "rpc"]))
|
#expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "clawdis", "rpc"]))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func pnpmKeepsExtraArgsAfterSubcommand() async throws {
|
@Test func pnpmKeepsExtraArgsAfterSubcommand() async throws {
|
||||||
UserDefaults.standard.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
|
let defaults = self.makeDefaults()
|
||||||
UserDefaults.standard.removeObject(forKey: remoteTargetKey)
|
defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
|
||||||
UserDefaults.standard.removeObject(forKey: remoteIdentityKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteProjectRootKey)
|
|
||||||
defer {
|
|
||||||
UserDefaults.standard.removeObject(forKey: connectionModeKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteTargetKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteIdentityKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteProjectRootKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
let tmp = try makeTempDir()
|
let tmp = try makeTempDir()
|
||||||
CommandResolver.setProjectRoot(tmp.path)
|
CommandResolver.setProjectRoot(tmp.path)
|
||||||
@@ -112,24 +85,16 @@ import Testing
|
|||||||
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
|
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
|
||||||
try self.makeExec(at: pnpmPath)
|
try self.makeExec(at: pnpmPath)
|
||||||
|
|
||||||
let cmd = CommandResolver.clawdisCommand(subcommand: "health", extraArgs: ["--json", "--timeout", "5"])
|
let cmd = CommandResolver.clawdisCommand(
|
||||||
|
subcommand: "health",
|
||||||
|
extraArgs: ["--json", "--timeout", "5"],
|
||||||
|
defaults: defaults)
|
||||||
|
|
||||||
#expect(cmd.prefix(5).elementsEqual([pnpmPath.path, "--silent", "clawdis", "health", "--json"]))
|
#expect(cmd.prefix(5).elementsEqual([pnpmPath.path, "--silent", "clawdis", "health", "--json"]))
|
||||||
#expect(cmd.suffix(2).elementsEqual(["--timeout", "5"]))
|
#expect(cmd.suffix(2).elementsEqual(["--timeout", "5"]))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func preferredPathsStartWithProjectNodeBins() async throws {
|
@Test func preferredPathsStartWithProjectNodeBins() async throws {
|
||||||
UserDefaults.standard.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteTargetKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteIdentityKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteProjectRootKey)
|
|
||||||
defer {
|
|
||||||
UserDefaults.standard.removeObject(forKey: connectionModeKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteTargetKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteIdentityKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteProjectRootKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
let tmp = try makeTempDir()
|
let tmp = try makeTempDir()
|
||||||
CommandResolver.setProjectRoot(tmp.path)
|
CommandResolver.setProjectRoot(tmp.path)
|
||||||
|
|
||||||
@@ -138,18 +103,13 @@ import Testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func buildsSSHCommandForRemoteMode() async throws {
|
@Test func buildsSSHCommandForRemoteMode() async throws {
|
||||||
UserDefaults.standard.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
let defaults = self.makeDefaults()
|
||||||
UserDefaults.standard.set("clawd@example.com:2222", forKey: remoteTargetKey)
|
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
||||||
UserDefaults.standard.set("/tmp/id_ed25519", forKey: remoteIdentityKey)
|
defaults.set("clawd@example.com:2222", forKey: remoteTargetKey)
|
||||||
UserDefaults.standard.set("/srv/clawdis", forKey: remoteProjectRootKey)
|
defaults.set("/tmp/id_ed25519", forKey: remoteIdentityKey)
|
||||||
defer {
|
defaults.set("/srv/clawdis", forKey: remoteProjectRootKey)
|
||||||
UserDefaults.standard.removeObject(forKey: connectionModeKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteTargetKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteIdentityKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteProjectRootKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
let cmd = CommandResolver.clawdisCommand(subcommand: "status", extraArgs: ["--json"])
|
let cmd = CommandResolver.clawdisCommand(subcommand: "status", extraArgs: ["--json"], defaults: defaults)
|
||||||
|
|
||||||
#expect(cmd.first == "/usr/bin/ssh")
|
#expect(cmd.first == "/usr/bin/ssh")
|
||||||
#expect(cmd.contains("clawd@example.com"))
|
#expect(cmd.contains("clawd@example.com"))
|
||||||
|
|||||||
@@ -28,4 +28,71 @@ import Testing
|
|||||||
#expect(payload?["count"]?.value as? Int == 1)
|
#expect(payload?["count"]?.value as? Int == 1)
|
||||||
#expect(evt.seq == 7)
|
#expect(evt.seq == 7)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func decodesRequestFrameWithNestedParams() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"type": "req",
|
||||||
|
"id": "1",
|
||||||
|
"method": "agent.send",
|
||||||
|
"params": {
|
||||||
|
"text": "hi",
|
||||||
|
"items": [1, null, {"ok": true}],
|
||||||
|
"meta": { "count": 2 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
let frame = try JSONDecoder().decode(GatewayFrame.self, from: Data(json.utf8))
|
||||||
|
|
||||||
|
#expect({
|
||||||
|
if case .req = frame { true } else { false }
|
||||||
|
}(), "expected .req frame")
|
||||||
|
|
||||||
|
guard case let .req(req) = frame else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let params = req.params?.value as? [String: AnyCodable]
|
||||||
|
#expect(params?["text"]?.value as? String == "hi")
|
||||||
|
|
||||||
|
let items = params?["items"]?.value as? [AnyCodable]
|
||||||
|
#expect(items?.count == 3)
|
||||||
|
#expect(items?[0].value as? Int == 1)
|
||||||
|
#expect(items?[1].value is NSNull)
|
||||||
|
|
||||||
|
let item2 = items?[2].value as? [String: AnyCodable]
|
||||||
|
#expect(item2?["ok"]?.value as? Bool == true)
|
||||||
|
|
||||||
|
let meta = params?["meta"]?.value as? [String: AnyCodable]
|
||||||
|
#expect(meta?["count"]?.value as? Int == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decodesUnknownFrameAndPreservesRaw() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"type": "made-up",
|
||||||
|
"foo": "bar",
|
||||||
|
"count": 1,
|
||||||
|
"nested": { "ok": true }
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
let frame = try JSONDecoder().decode(GatewayFrame.self, from: Data(json.utf8))
|
||||||
|
|
||||||
|
#expect({
|
||||||
|
if case .unknown = frame { true } else { false }
|
||||||
|
}(), "expected .unknown frame")
|
||||||
|
|
||||||
|
guard case let .unknown(type, raw) = frame else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(type == "made-up")
|
||||||
|
#expect(raw["type"]?.value as? String == "made-up")
|
||||||
|
#expect(raw["foo"]?.value as? String == "bar")
|
||||||
|
#expect(raw["count"]?.value as? Int == 1)
|
||||||
|
let nested = raw["nested"]?.value as? [String: AnyCodable]
|
||||||
|
#expect(nested?["ok"]?.value as? Bool == true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Foundation
|
|||||||
import Testing
|
import Testing
|
||||||
@testable import Clawdis
|
@testable import Clawdis
|
||||||
|
|
||||||
@Suite struct UtilitiesTests {
|
@Suite(.serialized) struct UtilitiesTests {
|
||||||
@Test func ageStringsCoverCommonWindows() {
|
@Test func ageStringsCoverCommonWindows() {
|
||||||
let now = Date(timeIntervalSince1970: 1_000_000)
|
let now = Date(timeIntervalSince1970: 1_000_000)
|
||||||
#expect(age(from: now, now: now) == "just now")
|
#expect(age(from: now, now: now) == "just now")
|
||||||
@@ -33,14 +33,11 @@ import Testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func sanitizedTargetStripsLeadingSSHPrefix() {
|
@Test func sanitizedTargetStripsLeadingSSHPrefix() {
|
||||||
UserDefaults.standard.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
let defaults = UserDefaults(suiteName: "UtilitiesTests.\(UUID().uuidString)")!
|
||||||
UserDefaults.standard.set("ssh alice@example.com", forKey: remoteTargetKey)
|
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
||||||
defer {
|
defaults.set("ssh alice@example.com", forKey: remoteTargetKey)
|
||||||
UserDefaults.standard.removeObject(forKey: connectionModeKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: remoteTargetKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
let settings = CommandResolver.connectionSettings()
|
let settings = CommandResolver.connectionSettings(defaults: defaults)
|
||||||
#expect(settings.mode == .remote)
|
#expect(settings.mode == .remote)
|
||||||
#expect(settings.target == "alice@example.com")
|
#expect(settings.target == "alice@example.com")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,26 +157,29 @@ function emitGatewayFrame(): string {
|
|||||||
};
|
};
|
||||||
const caseLines = cases.map((c) => ` case ${safeName(c)}(${associated[c]})`);
|
const caseLines = cases.map((c) => ` case ${safeName(c)}(${associated[c]})`);
|
||||||
const initLines = `
|
const initLines = `
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case type
|
||||||
|
}
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.singleValueContainer()
|
let typeContainer = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
let raw = try container.decode([String: AnyCodable].self)
|
let type = try typeContainer.decode(String.self, forKey: .type)
|
||||||
guard let type = raw["type"]?.value as? String else {
|
|
||||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "missing type")
|
|
||||||
}
|
|
||||||
switch type {
|
switch type {
|
||||||
case "hello":
|
case "hello":
|
||||||
self = .hello(try Self.decodePayload(Hello.self, from: raw))
|
self = .hello(try Hello(from: decoder))
|
||||||
case "hello-ok":
|
case "hello-ok":
|
||||||
self = .helloOk(try Self.decodePayload(HelloOk.self, from: raw))
|
self = .helloOk(try HelloOk(from: decoder))
|
||||||
case "hello-error":
|
case "hello-error":
|
||||||
self = .helloError(try Self.decodePayload(HelloError.self, from: raw))
|
self = .helloError(try HelloError(from: decoder))
|
||||||
case "req":
|
case "req":
|
||||||
self = .req(try Self.decodePayload(RequestFrame.self, from: raw))
|
self = .req(try RequestFrame(from: decoder))
|
||||||
case "res":
|
case "res":
|
||||||
self = .res(try Self.decodePayload(ResponseFrame.self, from: raw))
|
self = .res(try ResponseFrame(from: decoder))
|
||||||
case "event":
|
case "event":
|
||||||
self = .event(try Self.decodePayload(EventFrame.self, from: raw))
|
self = .event(try EventFrame(from: decoder))
|
||||||
default:
|
default:
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
let raw = try container.decode([String: AnyCodable].self)
|
||||||
self = .unknown(type: type, raw: raw)
|
self = .unknown(type: type, raw: raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,22 +199,11 @@ function emitGatewayFrame(): string {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const helper = `
|
|
||||||
private static func decodePayload<T: Decodable>(_ type: T.Type, from raw: [String: AnyCodable]) throws -> T {
|
|
||||||
// raw is [String: AnyCodable] which is not directly JSONSerialization-compatible.
|
|
||||||
// Round-trip through JSONEncoder so AnyCodable can encode itself safely.
|
|
||||||
let data = try JSONEncoder().encode(raw)
|
|
||||||
let decoder = JSONDecoder()
|
|
||||||
return try decoder.decode(T.self, from: data)
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"public enum GatewayFrame: Codable {",
|
"public enum GatewayFrame: Codable {",
|
||||||
...caseLines,
|
...caseLines,
|
||||||
" case unknown(type: String, raw: [String: AnyCodable])",
|
" case unknown(type: String, raw: [String: AnyCodable])",
|
||||||
initLines,
|
initLines,
|
||||||
helper,
|
|
||||||
"}",
|
"}",
|
||||||
"",
|
"",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|||||||
Reference in New Issue
Block a user