test: expand menu and node coverage
This commit is contained in:
@@ -503,3 +503,40 @@ enum BridgePairingApprover {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension BridgeServer {
|
||||||
|
func exerciseForTesting() async {
|
||||||
|
let conn = NWConnection(to: .hostPort(host: "127.0.0.1", port: 22), using: .tcp)
|
||||||
|
let handler = BridgeConnectionHandler(connection: conn, logger: self.logger)
|
||||||
|
self.connections["node-1"] = handler
|
||||||
|
self.nodeInfoById["node-1"] = BridgeNodeInfo(
|
||||||
|
nodeId: "node-1",
|
||||||
|
displayName: "Node One",
|
||||||
|
platform: "macOS",
|
||||||
|
version: "1.0.0",
|
||||||
|
deviceFamily: "Mac",
|
||||||
|
modelIdentifier: "MacBookPro18,1",
|
||||||
|
remoteAddress: "127.0.0.1",
|
||||||
|
caps: ["chat", "voice"])
|
||||||
|
|
||||||
|
_ = self.connectedNodeIds()
|
||||||
|
_ = self.connectedNodes()
|
||||||
|
|
||||||
|
self.handleListenerState(.ready)
|
||||||
|
self.handleListenerState(.failed(NWError.posix(.ECONNREFUSED)))
|
||||||
|
self.handleListenerState(.waiting(NWError.posix(.ETIMEDOUT)))
|
||||||
|
self.handleListenerState(.cancelled)
|
||||||
|
self.handleListenerState(.setup)
|
||||||
|
|
||||||
|
let subscribe = BridgeEventFrame(event: "chat.subscribe", payloadJSON: "{\"sessionKey\":\"main\"}")
|
||||||
|
await self.handleEvent(nodeId: "node-1", evt: subscribe)
|
||||||
|
|
||||||
|
let unsubscribe = BridgeEventFrame(event: "chat.unsubscribe", payloadJSON: "{\"sessionKey\":\"main\"}")
|
||||||
|
await self.handleEvent(nodeId: "node-1", evt: unsubscribe)
|
||||||
|
|
||||||
|
let invalid = BridgeRPCRequest(id: "req-1", method: "invalid.method", paramsJSON: nil)
|
||||||
|
_ = await self.handleRequest(nodeId: "node-1", req: invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
private var cachedErrorText: String?
|
private var cachedErrorText: String?
|
||||||
private var cacheUpdatedAt: Date?
|
private var cacheUpdatedAt: Date?
|
||||||
private let refreshIntervalSeconds: TimeInterval = 12
|
private let refreshIntervalSeconds: TimeInterval = 12
|
||||||
|
#if DEBUG
|
||||||
|
private var testControlChannelConnected: Bool?
|
||||||
|
#endif
|
||||||
|
|
||||||
func install(into statusItem: NSStatusItem) {
|
func install(into statusItem: NSStatusItem) {
|
||||||
self.statusItem = statusItem
|
self.statusItem = statusItem
|
||||||
@@ -157,6 +160,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var isControlChannelConnected: Bool {
|
private var isControlChannelConnected: Bool {
|
||||||
|
#if DEBUG
|
||||||
|
if let override = self.testControlChannelConnected { return override }
|
||||||
|
#endif
|
||||||
if case .connected = ControlChannel.shared.state { return true }
|
if case .connected = ControlChannel.shared.state { return true }
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -467,6 +473,24 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension MenuSessionsInjector {
|
||||||
|
func setTestingControlChannelConnected(_ connected: Bool?) {
|
||||||
|
self.testControlChannelConnected = connected
|
||||||
|
}
|
||||||
|
|
||||||
|
func setTestingSnapshot(_ snapshot: SessionStoreSnapshot?, errorText: String? = nil) {
|
||||||
|
self.cachedSnapshot = snapshot
|
||||||
|
self.cachedErrorText = errorText
|
||||||
|
self.cacheUpdatedAt = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
func injectForTesting(into menu: NSMenu) {
|
||||||
|
self.inject(into: menu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private final class HighlightedMenuItemHostView: NSView {
|
private final class HighlightedMenuItemHostView: NSView {
|
||||||
private let baseView: AnyView
|
private let baseView: AnyView
|
||||||
private let hosting: NSHostingView<AnyView>
|
private let hosting: NSHostingView<AnyView>
|
||||||
|
|||||||
@@ -653,3 +653,40 @@ final class NodePairingApprovalPrompter {
|
|||||||
self.updateReconcileLoop()
|
self.updateReconcileLoop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
@MainActor
|
||||||
|
extension NodePairingApprovalPrompter {
|
||||||
|
static func exerciseForTesting() async {
|
||||||
|
let prompter = NodePairingApprovalPrompter()
|
||||||
|
let pending = PendingRequest(
|
||||||
|
requestId: "req-1",
|
||||||
|
nodeId: "node-1",
|
||||||
|
displayName: "Node One",
|
||||||
|
platform: "macos",
|
||||||
|
version: "1.0.0",
|
||||||
|
remoteIp: "127.0.0.1",
|
||||||
|
isRepair: false,
|
||||||
|
silent: true,
|
||||||
|
ts: 1_700_000_000_000)
|
||||||
|
let paired = PairedNode(
|
||||||
|
nodeId: "node-1",
|
||||||
|
approvedAtMs: 1_700_000_000_000,
|
||||||
|
displayName: "Node One",
|
||||||
|
platform: "macOS",
|
||||||
|
version: "1.0.0",
|
||||||
|
remoteIp: "127.0.0.1")
|
||||||
|
let list = PairingList(pending: [pending], paired: [paired])
|
||||||
|
|
||||||
|
_ = Self.describe(pending)
|
||||||
|
_ = Self.prettyIP(pending.remoteIp)
|
||||||
|
_ = Self.prettyPlatform(pending.platform)
|
||||||
|
_ = prompter.inferResolution(for: pending, list: list)
|
||||||
|
|
||||||
|
prompter.queue = [pending]
|
||||||
|
_ = prompter.shouldPoll
|
||||||
|
_ = await prompter.trySilentApproveIfPossible(pending)
|
||||||
|
prompter.queue.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
10
apps/macos/Tests/ClawdisIPCTests/BridgeServerTests.swift
Normal file
10
apps/macos/Tests/ClawdisIPCTests/BridgeServerTests.swift
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite(.serialized)
|
||||||
|
struct BridgeServerTests {
|
||||||
|
@Test func bridgeServerExercisesPaths() async {
|
||||||
|
let server = BridgeServer()
|
||||||
|
await server.exerciseForTesting()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import Testing
|
import Testing
|
||||||
@@ -39,17 +40,7 @@ import Testing
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func probeEndpointFailsForClosedPort() async throws {
|
@Test func probeEndpointFailsForClosedPort() async throws {
|
||||||
let listener = try NWListener(using: .tcp, on: .any)
|
let port = try reserveEphemeralPort()
|
||||||
listener.start(queue: DispatchQueue(label: "com.steipete.clawdis.tests.bridge-listener-close"))
|
|
||||||
try await waitForListenerReady(listener, timeoutSeconds: 1.0)
|
|
||||||
let port = listener.port
|
|
||||||
listener.cancel()
|
|
||||||
try await Task.sleep(nanoseconds: 150_000_000)
|
|
||||||
|
|
||||||
guard let port else {
|
|
||||||
throw TestError(message: "listener port missing")
|
|
||||||
}
|
|
||||||
|
|
||||||
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port)
|
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port)
|
||||||
let ok = await MacNodeModeCoordinator.probeEndpoint(endpoint, timeoutSeconds: 0.4)
|
let ok = await MacNodeModeCoordinator.probeEndpoint(endpoint, timeoutSeconds: 0.4)
|
||||||
#expect(ok == false)
|
#expect(ok == false)
|
||||||
@@ -118,3 +109,43 @@ private func withEnv(_ key: String, value: String?, _ body: () -> Void) {
|
|||||||
}
|
}
|
||||||
body()
|
body()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func reserveEphemeralPort() throws -> NWEndpoint.Port {
|
||||||
|
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||||
|
if fd < 0 {
|
||||||
|
throw TestError(message: "socket failed")
|
||||||
|
}
|
||||||
|
defer { close(fd) }
|
||||||
|
|
||||||
|
var addr = sockaddr_in()
|
||||||
|
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
||||||
|
addr.sin_family = sa_family_t(AF_INET)
|
||||||
|
addr.sin_port = in_port_t(0)
|
||||||
|
addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
|
||||||
|
|
||||||
|
let bindResult = withUnsafePointer(to: &addr) { pointer -> Int32 in
|
||||||
|
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||||
|
Darwin.bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bindResult != 0 {
|
||||||
|
throw TestError(message: "bind failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolved = sockaddr_in()
|
||||||
|
var length = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||||
|
let nameResult = withUnsafeMutablePointer(to: &resolved) { pointer -> Int32 in
|
||||||
|
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||||
|
getsockname(fd, $0, &length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nameResult != 0 {
|
||||||
|
throw TestError(message: "getsockname failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
let port = UInt16(bigEndian: resolved.sin_port)
|
||||||
|
guard let endpointPort = NWEndpoint.Port(rawValue: port), endpointPort.rawValue != 0 else {
|
||||||
|
throw TestError(message: "ephemeral port missing")
|
||||||
|
}
|
||||||
|
return endpointPort
|
||||||
|
}
|
||||||
|
|||||||
32
apps/macos/Tests/ClawdisIPCTests/MacNodeRuntimeTests.swift
Normal file
32
apps/macos/Tests/ClawdisIPCTests/MacNodeRuntimeTests.swift
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import ClawdisKit
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite(.serialized)
|
||||||
|
struct MacNodeRuntimeTests {
|
||||||
|
@Test func handleInvokeRejectsUnknownCommand() async {
|
||||||
|
let runtime = MacNodeRuntime()
|
||||||
|
let response = await runtime.handleInvoke(
|
||||||
|
BridgeInvokeRequest(id: "req-1", command: "unknown.command"))
|
||||||
|
#expect(response.ok == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func handleInvokeRejectsEmptySystemRun() async throws {
|
||||||
|
let runtime = MacNodeRuntime()
|
||||||
|
let params = ClawdisSystemRunParams(command: [])
|
||||||
|
let json = String(data: try JSONEncoder().encode(params), encoding: .utf8)
|
||||||
|
let response = await runtime.handleInvoke(
|
||||||
|
BridgeInvokeRequest(id: "req-2", command: ClawdisSystemCommand.run.rawValue, paramsJSON: json))
|
||||||
|
#expect(response.ok == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func handleInvokeRejectsEmptyNotification() async throws {
|
||||||
|
let runtime = MacNodeRuntime()
|
||||||
|
let params = ClawdisSystemNotifyParams(title: "", body: "")
|
||||||
|
let json = String(data: try JSONEncoder().encode(params), encoding: .utf8)
|
||||||
|
let response = await runtime.handleInvoke(
|
||||||
|
BridgeInvokeRequest(id: "req-3", command: ClawdisSystemCommand.notify.rawValue, paramsJSON: json))
|
||||||
|
#expect(response.ok == false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,4 +25,17 @@ struct MenuContentSmokeTests {
|
|||||||
let view = MenuContent(state: state, updater: nil)
|
let view = MenuContent(state: state, updater: nil)
|
||||||
_ = view.body
|
_ = view.body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func menuContentBuildsBodyWithDebugAndCanvas() {
|
||||||
|
let state = AppState(preview: true)
|
||||||
|
state.connectionMode = .local
|
||||||
|
state.debugPaneEnabled = true
|
||||||
|
state.canvasEnabled = true
|
||||||
|
state.canvasPanelVisible = true
|
||||||
|
state.swabbleEnabled = true
|
||||||
|
state.voicePushToTalkEnabled = true
|
||||||
|
state.heartbeatsEnabled = true
|
||||||
|
let view = MenuContent(state: state, updater: nil)
|
||||||
|
_ = view.body
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import AppKit
|
||||||
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite(.serialized)
|
||||||
|
@MainActor
|
||||||
|
struct MenuSessionsInjectorTests {
|
||||||
|
@Test func injectsDisconnectedMessage() {
|
||||||
|
let injector = MenuSessionsInjector()
|
||||||
|
injector.setTestingControlChannelConnected(false)
|
||||||
|
injector.setTestingSnapshot(nil, errorText: nil)
|
||||||
|
|
||||||
|
let menu = NSMenu()
|
||||||
|
menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: ""))
|
||||||
|
menu.addItem(.separator())
|
||||||
|
menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: ""))
|
||||||
|
|
||||||
|
injector.injectForTesting(into: menu)
|
||||||
|
#expect(menu.items.contains { $0.tag == 9_415_557 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func injectsSessionRows() {
|
||||||
|
let injector = MenuSessionsInjector()
|
||||||
|
injector.setTestingControlChannelConnected(true)
|
||||||
|
|
||||||
|
let defaults = SessionDefaults(model: "claude-opus-4-5", contextTokens: 200_000)
|
||||||
|
let rows = [
|
||||||
|
SessionRow(
|
||||||
|
id: "main",
|
||||||
|
key: "main",
|
||||||
|
kind: .direct,
|
||||||
|
updatedAt: Date(),
|
||||||
|
sessionId: "s1",
|
||||||
|
thinkingLevel: "low",
|
||||||
|
verboseLevel: nil,
|
||||||
|
systemSent: false,
|
||||||
|
abortedLastRun: false,
|
||||||
|
tokens: SessionTokenStats(input: 10, output: 20, total: 30, contextTokens: 200_000),
|
||||||
|
model: "claude-opus-4-5"),
|
||||||
|
SessionRow(
|
||||||
|
id: "group:alpha",
|
||||||
|
key: "group:alpha",
|
||||||
|
kind: .group,
|
||||||
|
updatedAt: Date(timeIntervalSinceNow: -60),
|
||||||
|
sessionId: "s2",
|
||||||
|
thinkingLevel: "high",
|
||||||
|
verboseLevel: "debug",
|
||||||
|
systemSent: true,
|
||||||
|
abortedLastRun: true,
|
||||||
|
tokens: SessionTokenStats(input: 50, output: 50, total: 100, contextTokens: 200_000),
|
||||||
|
model: "claude-opus-4-5"),
|
||||||
|
]
|
||||||
|
let snapshot = SessionStoreSnapshot(
|
||||||
|
storePath: "/tmp/sessions.json",
|
||||||
|
defaults: defaults,
|
||||||
|
rows: rows)
|
||||||
|
injector.setTestingSnapshot(snapshot, errorText: nil)
|
||||||
|
|
||||||
|
let menu = NSMenu()
|
||||||
|
menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: ""))
|
||||||
|
menu.addItem(.separator())
|
||||||
|
menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: ""))
|
||||||
|
|
||||||
|
injector.injectForTesting(into: menu)
|
||||||
|
#expect(menu.items.contains { $0.tag == 9_415_557 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite(.serialized)
|
||||||
|
@MainActor
|
||||||
|
struct NodePairingApprovalPrompterTests {
|
||||||
|
@Test func nodePairingApprovalPrompterExercises() async {
|
||||||
|
await NodePairingApprovalPrompter.exerciseForTesting()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user