From 49e466dd406849805c1db256fd49a6b3652923ef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 24 Dec 2025 17:42:24 +0100 Subject: [PATCH] test: expand menu and node coverage --- .../Sources/Clawdis/Bridge/BridgeServer.swift | 37 ++++++++++ .../Clawdis/MenuSessionsInjector.swift | 24 +++++++ .../Clawdis/NodePairingApprovalPrompter.swift | 37 ++++++++++ .../ClawdisIPCTests/BridgeServerTests.swift | 10 +++ .../MacNodeBridgeDiscoveryTests.swift | 53 ++++++++++++--- .../ClawdisIPCTests/MacNodeRuntimeTests.swift | 32 +++++++++ .../MenuContentSmokeTests.swift | 13 ++++ .../MenuSessionsInjectorTests.swift | 67 +++++++++++++++++++ .../NodePairingApprovalPrompterTests.swift | 10 +++ 9 files changed, 272 insertions(+), 11 deletions(-) create mode 100644 apps/macos/Tests/ClawdisIPCTests/BridgeServerTests.swift create mode 100644 apps/macos/Tests/ClawdisIPCTests/MacNodeRuntimeTests.swift create mode 100644 apps/macos/Tests/ClawdisIPCTests/MenuSessionsInjectorTests.swift create mode 100644 apps/macos/Tests/ClawdisIPCTests/NodePairingApprovalPrompterTests.swift diff --git a/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift b/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift index c7f24870f..509522eb5 100644 --- a/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift +++ b/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift @@ -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 diff --git a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift index 77cde5e1f..9ad533756 100644 --- a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift @@ -19,6 +19,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { private var cachedErrorText: String? private var cacheUpdatedAt: Date? private let refreshIntervalSeconds: TimeInterval = 12 +#if DEBUG + private var testControlChannelConnected: Bool? +#endif func install(into statusItem: NSStatusItem) { self.statusItem = statusItem @@ -157,6 +160,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { } private var isControlChannelConnected: Bool { +#if DEBUG + if let override = self.testControlChannelConnected { return override } +#endif if case .connected = ControlChannel.shared.state { return true } 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 let baseView: AnyView private let hosting: NSHostingView diff --git a/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift b/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift index 679e0d759..85d8a9f12 100644 --- a/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift +++ b/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift @@ -653,3 +653,40 @@ final class NodePairingApprovalPrompter { 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 diff --git a/apps/macos/Tests/ClawdisIPCTests/BridgeServerTests.swift b/apps/macos/Tests/ClawdisIPCTests/BridgeServerTests.swift new file mode 100644 index 000000000..3df2962e1 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/BridgeServerTests.swift @@ -0,0 +1,10 @@ +import Testing +@testable import Clawdis + +@Suite(.serialized) +struct BridgeServerTests { + @Test func bridgeServerExercisesPaths() async { + let server = BridgeServer() + await server.exerciseForTesting() + } +} diff --git a/apps/macos/Tests/ClawdisIPCTests/MacNodeBridgeDiscoveryTests.swift b/apps/macos/Tests/ClawdisIPCTests/MacNodeBridgeDiscoveryTests.swift index 6859f8791..67d46b691 100644 --- a/apps/macos/Tests/ClawdisIPCTests/MacNodeBridgeDiscoveryTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/MacNodeBridgeDiscoveryTests.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation import Network import Testing @@ -39,17 +40,7 @@ import Testing @MainActor @Test func probeEndpointFailsForClosedPort() async throws { - let listener = try NWListener(using: .tcp, on: .any) - 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 port = try reserveEphemeralPort() let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port) let ok = await MacNodeModeCoordinator.probeEndpoint(endpoint, timeoutSeconds: 0.4) #expect(ok == false) @@ -118,3 +109,43 @@ private func withEnv(_ key: String, value: String?, _ body: () -> Void) { } 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.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.size)) + } + } + if bindResult != 0 { + throw TestError(message: "bind failed") + } + + var resolved = sockaddr_in() + var length = socklen_t(MemoryLayout.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 +} diff --git a/apps/macos/Tests/ClawdisIPCTests/MacNodeRuntimeTests.swift b/apps/macos/Tests/ClawdisIPCTests/MacNodeRuntimeTests.swift new file mode 100644 index 000000000..b10e7d0c5 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/MacNodeRuntimeTests.swift @@ -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) + } +} diff --git a/apps/macos/Tests/ClawdisIPCTests/MenuContentSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/MenuContentSmokeTests.swift index ceeac0569..8d2ac0423 100644 --- a/apps/macos/Tests/ClawdisIPCTests/MenuContentSmokeTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/MenuContentSmokeTests.swift @@ -25,4 +25,17 @@ struct MenuContentSmokeTests { let view = MenuContent(state: state, updater: nil) _ = 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 + } } diff --git a/apps/macos/Tests/ClawdisIPCTests/MenuSessionsInjectorTests.swift b/apps/macos/Tests/ClawdisIPCTests/MenuSessionsInjectorTests.swift new file mode 100644 index 000000000..cfd08d03b --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/MenuSessionsInjectorTests.swift @@ -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 }) + } +} diff --git a/apps/macos/Tests/ClawdisIPCTests/NodePairingApprovalPrompterTests.swift b/apps/macos/Tests/ClawdisIPCTests/NodePairingApprovalPrompterTests.swift new file mode 100644 index 000000000..2835c99d7 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/NodePairingApprovalPrompterTests.swift @@ -0,0 +1,10 @@ +import Testing +@testable import Clawdis + +@Suite(.serialized) +@MainActor +struct NodePairingApprovalPrompterTests { + @Test func nodePairingApprovalPrompterExercises() async { + await NodePairingApprovalPrompter.exerciseForTesting() + } +}