diff --git a/apps/macos/Sources/Clawdis/CronSettings.swift b/apps/macos/Sources/Clawdis/CronSettings.swift index 76cc6f78a..f14b23345 100644 --- a/apps/macos/Sources/Clawdis/CronSettings.swift +++ b/apps/macos/Sources/Clawdis/CronSettings.swift @@ -473,7 +473,7 @@ struct CronSettings: View { } } -private struct StatusPill: View { +struct StatusPill: View { let text: String let tint: Color @@ -488,7 +488,7 @@ private struct StatusPill: View { } } -private struct CronJobEditor: View { +struct CronJobEditor: View { let job: CronJob? @Binding var isSaving: Bool @Binding var error: String? diff --git a/apps/macos/Sources/Clawdis/DebugSettings.swift b/apps/macos/Sources/Clawdis/DebugSettings.swift index d1e653aa0..02e548d67 100644 --- a/apps/macos/Sources/Clawdis/DebugSettings.swift +++ b/apps/macos/Sources/Clawdis/DebugSettings.swift @@ -8,6 +8,7 @@ struct DebugSettings: View { @AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath @AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0 @AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue + @AppStorage(canvasEnabledKey) private var canvasEnabled: Bool = true @State private var modelsCount: Int? @State private var modelsLoading = false @State private var modelsError: String? diff --git a/apps/macos/Sources/Clawdis/SettingsRootView.swift b/apps/macos/Sources/Clawdis/SettingsRootView.swift index 34b86f6b6..11bd831d0 100644 --- a/apps/macos/Sources/Clawdis/SettingsRootView.swift +++ b/apps/macos/Sources/Clawdis/SettingsRootView.swift @@ -126,7 +126,7 @@ struct SettingsRootView: View { enum SettingsTab: CaseIterable { case general, tools, sessions, cron, config, instances, voiceWake, permissions, debug, about static let windowWidth: CGFloat = 658 // +10% (tabs fit better) - static let windowHeight: CGFloat = 790 // +10% + static let windowHeight: CGFloat = 790 // +10% (more room) var title: String { switch self { case .general: "General" diff --git a/apps/macos/Sources/Clawdis/Utilities.swift b/apps/macos/Sources/Clawdis/Utilities.swift index f277df620..5e84cc719 100644 --- a/apps/macos/Sources/Clawdis/Utilities.swift +++ b/apps/macos/Sources/Clawdis/Utilities.swift @@ -7,9 +7,12 @@ extension ProcessInfo { } var isRunningTests: Bool { - // SwiftPM test bundles are typically loaded from a `.xctest` bundle. - // We also check common XCTest env vars because some runners still set them. + // SwiftPM tests load one or more `.xctest` bundles. With Swift Testing, `Bundle.main` is not + // guaranteed to be the `.xctest` bundle, so check all loaded bundles. + if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true } if Bundle.main.bundleURL.pathExtension == "xctest" { return true } + + // Backwards-compatible fallbacks for runners that still set XCTest env vars. return self.environment["XCTestConfigurationFilePath"] != nil || self.environment["XCTestBundlePath"] != nil || self.environment["XCTestSessionIdentifier"] != nil diff --git a/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift b/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift index 5f9cd1a13..dc4515a75 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift @@ -9,6 +9,7 @@ final class VoiceWakeOverlayController: ObservableObject { static let shared = VoiceWakeOverlayController() private let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.overlay") + private let enableUI: Bool /// Keep the voice wake overlay above any other Clawdis windows, but below the system’s pop-up menus. /// (Menu bar menus typically live at `.popUpMenu`.) @@ -47,6 +48,10 @@ final class VoiceWakeOverlayController: ObservableObject { private let minHeight: CGFloat = 48 let closeOverflow: CGFloat = 10 + init(enableUI: Bool = true) { + self.enableUI = enableUI + } + @discardableResult func startSession( token: UUID = UUID(), @@ -207,7 +212,23 @@ final class VoiceWakeOverlayController: ObservableObject { self.autoSendTask?.cancel(); self.autoSendToken = nil self.model.isSending = false self.model.isEditing = false - guard let window else { return } + + if !self.enableUI { + self.model.isVisible = false + self.model.level = 0 + self.activeToken = nil + self.activeSource = nil + return + } + guard let window else { + if ProcessInfo.processInfo.isRunningTests { + self.model.isVisible = false + self.model.level = 0 + self.activeToken = nil + self.activeSource = nil + } + return + } let target = self.dismissTargetFrame(for: window.frame, reason: reason, outcome: outcome) NSAnimationContext.runAnimationGroup { context in context.duration = 0.18 @@ -273,6 +294,12 @@ final class VoiceWakeOverlayController: ObservableObject { } private func present() { + if !self.enableUI || ProcessInfo.processInfo.isRunningTests { + if !self.model.isVisible { + self.model.isVisible = true + } + return + } self.ensureWindow() self.hostingView?.rootView = VoiceWakeOverlayView(controller: self) let target = self.targetFrame() @@ -437,7 +464,7 @@ final class VoiceWakeOverlayController: ObservableObject { } } -private struct VoiceWakeOverlayView: View { +struct VoiceWakeOverlayView: View { @ObservedObject var controller: VoiceWakeOverlayController @FocusState private var textFocused: Bool @State private var isHovering: Bool = false @@ -554,7 +581,7 @@ private struct VoiceWakeOverlayView: View { } } -private struct TranscriptTextView: NSViewRepresentable { +struct TranscriptTextView: NSViewRepresentable { @Binding var text: String var attributed: NSAttributedString var isFinal: Bool @@ -651,7 +678,7 @@ private struct TranscriptTextView: NSViewRepresentable { // MARK: - Vibrant display label -private struct VibrantLabelView: NSViewRepresentable { +struct VibrantLabelView: NSViewRepresentable { var attributed: NSAttributedString var onTap: () -> Void @@ -708,7 +735,7 @@ private final class ClickCatcher: NSView { } } -private struct CloseHoverButton: View { +struct CloseHoverButton: View { var onClose: () -> Void var body: some View { @@ -728,7 +755,7 @@ private struct CloseHoverButton: View { } } -private struct CloseButtonOverlay: View { +struct CloseButtonOverlay: View { var isVisible: Bool var onHover: (Bool) -> Void var onClose: () -> Void diff --git a/apps/macos/Sources/Clawdis/WebChatServer.swift b/apps/macos/Sources/Clawdis/WebChatServer.swift index 057a96372..4392444c4 100644 --- a/apps/macos/Sources/Clawdis/WebChatServer.swift +++ b/apps/macos/Sources/Clawdis/WebChatServer.swift @@ -81,6 +81,15 @@ final class WebChatServer: @unchecked Sendable { } } + func stop() { + self.queue.async { + self.listener?.cancel() + self.listener = nil + self.root = nil + self.port = nil + } + } + /// Returns the base URL once the server is ready, otherwise nil. func baseURL() -> URL? { var url: URL? @@ -174,8 +183,10 @@ final class WebChatServer: @unchecked Sendable { } let fileURL = root.appendingPathComponent(path) webChatServerLogger.debug("WebChatServer resolved file=\(fileURL.path, privacy: .public)") - // Simple directory traversal guard: served files must live under the bundled web root. - guard fileURL.path.hasPrefix(root.path) else { + // Directory traversal + symlink escape guard: served files must resolve under the bundled web root. + let rootPath = root.standardizedFileURL.resolvingSymlinksInPath().path + let resolvedPath = fileURL.standardizedFileURL.resolvingSymlinksInPath().path + guard resolvedPath == rootPath || resolvedPath.hasPrefix(rootPath + "/") else { let forbidden = Data("Forbidden".utf8) self.send( status: 403, diff --git a/apps/macos/Sources/Clawdis/WindowPlacement.swift b/apps/macos/Sources/Clawdis/WindowPlacement.swift index e069d6219..8fed3d570 100644 --- a/apps/macos/Sources/Clawdis/WindowPlacement.swift +++ b/apps/macos/Sources/Clawdis/WindowPlacement.swift @@ -4,6 +4,19 @@ import AppKit enum WindowPlacement { static func centeredFrame(size: NSSize, on screen: NSScreen? = NSScreen.main) -> NSRect { let bounds = (screen?.visibleFrame ?? NSScreen.screens.first?.visibleFrame ?? .zero) + return self.centeredFrame(size: size, in: bounds) + } + + static func topRightFrame( + size: NSSize, + padding: CGFloat, + on screen: NSScreen? = NSScreen.main) -> NSRect + { + let bounds = (screen?.visibleFrame ?? NSScreen.screens.first?.visibleFrame ?? .zero) + return self.topRightFrame(size: size, padding: padding, in: bounds) + } + + static func centeredFrame(size: NSSize, in bounds: NSRect) -> NSRect { if bounds == .zero { return NSRect(origin: .zero, size: size) } @@ -16,12 +29,7 @@ enum WindowPlacement { return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight) } - static func topRightFrame( - size: NSSize, - padding: CGFloat, - on screen: NSScreen? = NSScreen.main) -> NSRect - { - let bounds = (screen?.visibleFrame ?? NSScreen.screens.first?.visibleFrame ?? .zero) + static func topRightFrame(size: NSSize, padding: CGFloat, in bounds: NSRect) -> NSRect { if bounds == .zero { return NSRect(origin: .zero, size: size) } diff --git a/apps/macos/Tests/ClawdisIPCTests/AgentEventStoreTests.swift b/apps/macos/Tests/ClawdisIPCTests/AgentEventStoreTests.swift new file mode 100644 index 000000000..c4b02a764 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/AgentEventStoreTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing +@testable import Clawdis + +@Suite +@MainActor +struct AgentEventStoreTests { + @Test + func appendAndClear() { + let store = AgentEventStore() + #expect(store.events.isEmpty) + + store.append(ControlAgentEvent( + runId: "run", + seq: 1, + stream: "test", + ts: 0, + data: [:] as [String: AnyCodable], + summary: nil)) + #expect(store.events.count == 1) + + store.clear() + #expect(store.events.isEmpty) + } + + @Test + func trimsToMaxEvents() { + let store = AgentEventStore() + for i in 1...401 { + store.append(ControlAgentEvent( + runId: "run", + seq: i, + stream: "test", + ts: Double(i), + data: [:] as [String: AnyCodable], + summary: nil)) + } + + #expect(store.events.count == 400) + #expect(store.events.first?.seq == 2) + #expect(store.events.last?.seq == 401) + } +} diff --git a/apps/macos/Tests/ClawdisIPCTests/AgentWorkspaceTests.swift b/apps/macos/Tests/ClawdisIPCTests/AgentWorkspaceTests.swift new file mode 100644 index 000000000..9361e2b75 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/AgentWorkspaceTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing +@testable import Clawdis + +@Suite +struct AgentWorkspaceTests { + @Test + func displayPathUsesTildeForHome() { + let home = FileManager.default.homeDirectoryForCurrentUser + #expect(AgentWorkspace.displayPath(for: home) == "~") + + let inside = home.appendingPathComponent("Projects", isDirectory: true) + #expect(AgentWorkspace.displayPath(for: inside).hasPrefix("~/")) + } + + @Test + func resolveWorkspaceURLExpandsTilde() { + let url = AgentWorkspace.resolveWorkspaceURL(from: "~/tmp") + #expect(url.path.hasSuffix("/tmp")) + } + + @Test + func agentsURLAppendsFilename() { + let root = URL(fileURLWithPath: "/tmp/ws", isDirectory: true) + let url = AgentWorkspace.agentsURL(workspaceURL: root) + #expect(url.lastPathComponent == AgentWorkspace.agentsFilename) + } + + @Test + func bootstrapCreatesAgentsFileWhenMissing() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("clawdis-ws-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: tmp) } + + let agentsURL = try AgentWorkspace.bootstrap(workspaceURL: tmp) + #expect(FileManager.default.fileExists(atPath: agentsURL.path)) + + let contents = try String(contentsOf: agentsURL, encoding: .utf8) + #expect(contents.contains("# AGENTS.md")) + + let second = try AgentWorkspace.bootstrap(workspaceURL: tmp) + #expect(second == agentsURL) + } +} + diff --git a/apps/macos/Tests/ClawdisIPCTests/CanvasWindowSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/CanvasWindowSmokeTests.swift new file mode 100644 index 000000000..c00a1aa23 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/CanvasWindowSmokeTests.swift @@ -0,0 +1,48 @@ +import AppKit +import ClawdisIPC +import Foundation +import Testing +@testable import Clawdis + +@Suite(.serialized) +@MainActor +struct CanvasWindowSmokeTests { + @Test func panelControllerShowsAndHides() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("clawdis-canvas-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let anchor = { NSRect(x: 200, y: 400, width: 40, height: 40) } + let controller = try CanvasWindowController( + sessionKey: " main/invalid⚡️ ", + root: root, + presentation: .panel(anchorProvider: anchor)) + + #expect(controller.directoryPath.contains("main_invalid__") == true) + + controller.applyPreferredPlacement(CanvasPlacement(x: 120, y: 200, width: 520, height: 680)) + controller.showCanvas(path: "/") + _ = await controller.eval(javaScript: "1 + 1") + controller.windowDidMove(Notification(name: NSWindow.didMoveNotification)) + controller.windowDidEndLiveResize(Notification(name: NSWindow.didEndLiveResizeNotification)) + controller.hideCanvas() + controller.close() + } + + @Test func windowControllerShowsAndCloses() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("clawdis-canvas-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let controller = try CanvasWindowController( + sessionKey: "main", + root: root, + presentation: .window) + + controller.showCanvas(path: "/") + controller.windowWillClose(Notification(name: NSWindow.willCloseNotification)) + controller.hideCanvas() + controller.close() + } +} + diff --git a/apps/macos/Tests/ClawdisIPCTests/ControlRequestHandlerTests.swift b/apps/macos/Tests/ClawdisIPCTests/ControlRequestHandlerTests.swift new file mode 100644 index 000000000..9145ad31d --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/ControlRequestHandlerTests.swift @@ -0,0 +1,62 @@ +import ClawdisIPC +import Foundation +import Testing +@testable import Clawdis + +@Suite(.serialized) +struct ControlRequestHandlerTests { + @Test + func statusReturnsReadyWhenNotPaused() async throws { + let defaults = UserDefaults.standard + let previous = defaults.object(forKey: pauseDefaultsKey) + defaults.set(false, forKey: pauseDefaultsKey) + defer { + if let previous { + defaults.set(previous, forKey: pauseDefaultsKey) + } else { + defaults.removeObject(forKey: pauseDefaultsKey) + } + } + + let res = try await ControlRequestHandler.process(request: .status) + #expect(res.ok == true) + #expect(res.message == "ready") + } + + @Test + func statusReturnsPausedWhenPaused() async throws { + let defaults = UserDefaults.standard + let previous = defaults.object(forKey: pauseDefaultsKey) + defaults.set(true, forKey: pauseDefaultsKey) + defer { + if let previous { + defaults.set(previous, forKey: pauseDefaultsKey) + } else { + defaults.removeObject(forKey: pauseDefaultsKey) + } + } + + let res = try await ControlRequestHandler.process(request: .status) + #expect(res.ok == false) + #expect(res.message == "clawdis paused") + } + + @Test + func nonStatusRequestsShortCircuitWhenPaused() async throws { + let defaults = UserDefaults.standard + let previous = defaults.object(forKey: pauseDefaultsKey) + defaults.set(true, forKey: pauseDefaultsKey) + defer { + if let previous { + defaults.set(previous, forKey: pauseDefaultsKey) + } else { + defaults.removeObject(forKey: pauseDefaultsKey) + } + } + + let res = try await ControlRequestHandler.process(request: .rpcStatus) + #expect(res.ok == false) + #expect(res.message == "clawdis paused") + } +} + diff --git a/apps/macos/Tests/ClawdisIPCTests/CritterIconRendererTests.swift b/apps/macos/Tests/ClawdisIPCTests/CritterIconRendererTests.swift new file mode 100644 index 000000000..243837a72 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/CritterIconRendererTests.swift @@ -0,0 +1,34 @@ +import AppKit +import Testing +@testable import Clawdis + +@Suite +@MainActor +struct CritterIconRendererTests { + @Test func makeIconRendersExpectedSize() { + let image = CritterIconRenderer.makeIcon( + blink: 0.25, + legWiggle: 0.5, + earWiggle: 0.2, + earScale: 1, + earHoles: true, + badge: nil) + + #expect(image.size.width == 18) + #expect(image.size.height == 18) + #expect(image.tiffRepresentation != nil) + } + + @Test func makeIconRendersWithBadge() { + let image = CritterIconRenderer.makeIcon( + blink: 0, + legWiggle: 0, + earWiggle: 0, + earScale: 1, + earHoles: false, + badge: .init(symbolName: "terminal.fill", prominence: .primary)) + + #expect(image.tiffRepresentation != nil) + } +} + diff --git a/apps/macos/Tests/ClawdisIPCTests/CronJobEditorSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/CronJobEditorSmokeTests.swift new file mode 100644 index 000000000..e0eb77d2a --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/CronJobEditorSmokeTests.swift @@ -0,0 +1,59 @@ +import SwiftUI +import Testing +@testable import Clawdis + +@Suite(.serialized) +@MainActor +struct CronJobEditorSmokeTests { + @Test func statusPillBuildsBody() { + _ = StatusPill(text: "ok", tint: .green).body + _ = StatusPill(text: "disabled", tint: .secondary).body + } + + @Test func cronJobEditorBuildsBodyForNewJob() { + let view = CronJobEditor( + job: nil, + isSaving: .constant(false), + error: .constant(nil), + onCancel: {}, + onSave: { _ in }) + _ = view.body + } + + @Test func cronJobEditorBuildsBodyForExistingJob() { + let job = CronJob( + id: "job-1", + name: "Daily summary", + enabled: true, + createdAtMs: 1_700_000_000_000, + updatedAtMs: 1_700_000_000_000, + schedule: .every(everyMs: 3_600_000, anchorMs: 1_700_000_000_000), + sessionTarget: .isolated, + wakeMode: .nextHeartbeat, + payload: .agentTurn( + message: "Summarize the last day", + thinking: "low", + timeoutSeconds: 120, + deliver: true, + channel: "whatsapp", + to: "+15551234567", + bestEffortDeliver: true), + isolation: CronIsolation(postToMainPrefix: "Cron"), + state: CronJobState( + nextRunAtMs: 1_700_000_100_000, + runningAtMs: nil, + lastRunAtMs: 1_700_000_050_000, + lastStatus: "ok", + lastError: nil, + lastDurationMs: 1000)) + + let view = CronJobEditor( + job: job, + isSaving: .constant(false), + error: .constant(nil), + onCancel: {}, + onSave: { _ in }) + _ = view.body + } +} + diff --git a/apps/macos/Tests/ClawdisIPCTests/MacGatewayChatTransportMappingTests.swift b/apps/macos/Tests/ClawdisIPCTests/MacGatewayChatTransportMappingTests.swift new file mode 100644 index 000000000..042a32bdc --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/MacGatewayChatTransportMappingTests.swift @@ -0,0 +1,93 @@ +import ClawdisChatUI +import ClawdisProtocol +import Testing +@testable import Clawdis + +@Suite struct MacGatewayChatTransportMappingTests { + @Test func snapshotMapsToHealth() { + let snapshot = Snapshot( + presence: [], + health: ClawdisProtocol.AnyCodable(["ok": ClawdisProtocol.AnyCodable(false)]), + stateversion: StateVersion(presence: 1, health: 1), + uptimems: 123) + let hello = HelloOk( + type: "hello", + _protocol: 2, + server: [:], + features: [:], + snapshot: snapshot, + policy: [:]) + + let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello)) + switch mapped { + case let .health(ok): + #expect(ok == false) + default: + Issue.record("expected .health from snapshot, got \(String(describing: mapped))") + } + } + + @Test func healthEventMapsToHealth() { + let frame = EventFrame( + type: "event", + event: "health", + payload: ClawdisProtocol.AnyCodable(["ok": ClawdisProtocol.AnyCodable(true)]), + seq: 1, + stateversion: nil) + + let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.event(frame)) + switch mapped { + case let .health(ok): + #expect(ok == true) + default: + Issue.record("expected .health from health event, got \(String(describing: mapped))") + } + } + + @Test func tickEventMapsToTick() { + let frame = EventFrame(type: "event", event: "tick", payload: nil, seq: 1, stateversion: nil) + let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.event(frame)) + #expect({ + if case .tick = mapped { return true } + return false + }()) + } + + @Test func chatEventMapsToChat() { + let payload: ClawdisProtocol.AnyCodable = ClawdisProtocol.AnyCodable([ + "runId": ClawdisProtocol.AnyCodable("run-1"), + "sessionKey": ClawdisProtocol.AnyCodable("main"), + "state": ClawdisProtocol.AnyCodable("final"), + ]) + let frame = EventFrame(type: "event", event: "chat", payload: payload, seq: 1, stateversion: nil) + let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.event(frame)) + + switch mapped { + case let .chat(chat): + #expect(chat.runId == "run-1") + #expect(chat.sessionKey == "main") + #expect(chat.state == "final") + default: + Issue.record("expected .chat from chat event, got \(String(describing: mapped))") + } + } + + @Test func unknownEventMapsToNil() { + let frame = EventFrame( + type: "event", + event: "unknown", + payload: ClawdisProtocol.AnyCodable(["a": ClawdisProtocol.AnyCodable(1)]), + seq: 1, + stateversion: nil) + let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.event(frame)) + #expect(mapped == nil) + } + + @Test func seqGapMapsToSeqGap() { + let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.seqGap(expected: 1, received: 9)) + #expect({ + if case .seqGap = mapped { return true } + return false + }()) + } +} diff --git a/apps/macos/Tests/ClawdisIPCTests/MasterDiscoveryMenuSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/MasterDiscoveryMenuSmokeTests.swift new file mode 100644 index 000000000..e91615444 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/MasterDiscoveryMenuSmokeTests.swift @@ -0,0 +1,46 @@ +import SwiftUI +import Testing +@testable import Clawdis + +@Suite(.serialized) +@MainActor +struct MasterDiscoveryMenuSmokeTests { + @Test func inlineListBuildsBodyWhenEmpty() { + let discovery = MasterDiscoveryModel() + discovery.statusText = "Searching…" + discovery.masters = [] + + let view = MasterDiscoveryInlineList(discovery: discovery, currentTarget: nil, onSelect: { _ in }) + _ = view.body + } + + @Test func inlineListBuildsBodyWithMasterAndSelection() { + let discovery = MasterDiscoveryModel() + discovery.statusText = "Found 1" + discovery.masters = [ + .init( + displayName: "Office Mac", + lanHost: "office.local", + tailnetDns: "office.tailnet-123.ts.net", + sshPort: 2222, + debugID: "office"), + ] + + let currentTarget = "\(NSUserName())@office.tailnet-123.ts.net:2222" + let view = MasterDiscoveryInlineList(discovery: discovery, currentTarget: currentTarget, onSelect: { _ in }) + _ = view.body + } + + @Test func menuBuildsBodyWithMasters() { + let discovery = MasterDiscoveryModel() + discovery.statusText = "Found 2" + discovery.masters = [ + .init(displayName: "A", lanHost: "a.local", tailnetDns: nil, sshPort: 22, debugID: "a"), + .init(displayName: "B", lanHost: nil, tailnetDns: "b.ts.net", sshPort: 22, debugID: "b"), + ] + + let view = MasterDiscoveryMenu(discovery: discovery, onSelect: { _ in }) + _ = view.body + } +} + diff --git a/apps/macos/Tests/ClawdisIPCTests/MenuContentSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/MenuContentSmokeTests.swift new file mode 100644 index 000000000..3a52943e6 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/MenuContentSmokeTests.swift @@ -0,0 +1,22 @@ +import SwiftUI +import Testing +@testable import Clawdis + +@Suite(.serialized) +@MainActor +struct MenuContentSmokeTests { + @Test func menuContentBuildsBodyLocalMode() { + let state = AppState(preview: true) + state.connectionMode = .local + let view = MenuContent(state: state, updater: nil) + _ = view.body + } + + @Test func menuContentBuildsBodyRemoteMode() { + let state = AppState(preview: true) + state.connectionMode = .remote + let view = MenuContent(state: state, updater: nil) + _ = view.body + } +} + diff --git a/apps/macos/Tests/ClawdisIPCTests/ModelCatalogLoaderTests.swift b/apps/macos/Tests/ClawdisIPCTests/ModelCatalogLoaderTests.swift new file mode 100644 index 000000000..0bf50ee24 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/ModelCatalogLoaderTests.swift @@ -0,0 +1,54 @@ +import Foundation +import Testing +@testable import Clawdis + +@Suite +struct ModelCatalogLoaderTests { + @Test + func loadParsesModelsFromTypeScriptAndSorts() async throws { + let src = """ + export const MODELS = { + openai: { + "gpt-4o-mini": { name: "GPT-4o mini", contextWindow: 128000 } satisfies any, + "gpt-4o": { name: "GPT-4o", contextWindow: 128000 } as any, + "gpt-3.5": { contextWindow: 16000 }, + }, + anthropic: { + "claude-3": { name: "Claude 3", contextWindow: 200000 }, + }, + }; + """ + + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("models-\(UUID().uuidString).ts") + defer { try? FileManager.default.removeItem(at: tmp) } + try src.write(to: tmp, atomically: true, encoding: .utf8) + + let choices = try await ModelCatalogLoader.load(from: tmp.path) + #expect(choices.count == 4) + #expect(choices.first?.provider == "anthropic") + #expect(choices.first?.id == "claude-3") + + let ids = Set(choices.map(\.id)) + #expect(ids == Set(["claude-3", "gpt-4o", "gpt-4o-mini", "gpt-3.5"])) + + let openai = choices.filter { $0.provider == "openai" } + let openaiNames = openai.map(\.name) + #expect(openaiNames == openaiNames.sorted { a, b in + a.localizedCaseInsensitiveCompare(b) == .orderedAscending + }) + } + + @Test + func loadWithNoExportReturnsEmptyChoices() async throws { + let src = "const NOPE = 1;" + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("models-\(UUID().uuidString).ts") + defer { try? FileManager.default.removeItem(at: tmp) } + try src.write(to: tmp, atomically: true, encoding: .utf8) + + let choices = try await ModelCatalogLoader.load(from: tmp.path) + #expect(choices.isEmpty) + } +} + diff --git a/apps/macos/Tests/ClawdisIPCTests/OnboardingViewSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/OnboardingViewSmokeTests.swift new file mode 100644 index 000000000..50a9ab731 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/OnboardingViewSmokeTests.swift @@ -0,0 +1,16 @@ +import SwiftUI +import Testing +@testable import Clawdis + +@Suite(.serialized) +@MainActor +struct OnboardingViewSmokeTests { + @Test func onboardingViewBuildsBody() { + let state = AppState(preview: true) + let view = OnboardingView( + state: state, + permissionMonitor: PermissionMonitor.shared, + discoveryModel: MasterDiscoveryModel()) + _ = view.body + } +} diff --git a/apps/macos/Tests/ClawdisIPCTests/PermissionManagerTests.swift b/apps/macos/Tests/ClawdisIPCTests/PermissionManagerTests.swift new file mode 100644 index 000000000..4352a3b35 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/PermissionManagerTests.swift @@ -0,0 +1,25 @@ +import Testing +import ClawdisIPC +@testable import Clawdis + +@Suite(.serialized) +@MainActor +struct PermissionManagerTests { + @Test func voiceWakePermissionHelpersMatchStatus() async { + let direct = PermissionManager.voiceWakePermissionsGranted() + let ensured = await PermissionManager.ensureVoiceWakePermissions(interactive: false) + #expect(ensured == direct) + } + + @Test func statusCanQueryNonInteractiveCaps() async { + let caps: [Capability] = [.microphone, .speechRecognition, .screenRecording] + let status = await PermissionManager.status(caps) + #expect(status.keys.count == caps.count) + } + + @Test func ensureNonInteractiveDoesNotThrow() async { + let caps: [Capability] = [.microphone, .speechRecognition, .screenRecording] + let ensured = await PermissionManager.ensure(caps, interactive: false) + #expect(ensured.keys.count == caps.count) + } +} diff --git a/apps/macos/Tests/ClawdisIPCTests/VoiceWakeOverlayControllerTests.swift b/apps/macos/Tests/ClawdisIPCTests/VoiceWakeOverlayControllerTests.swift new file mode 100644 index 000000000..b1afd122b --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/VoiceWakeOverlayControllerTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +@testable import Clawdis + +@Suite(.serialized) +@MainActor +struct VoiceWakeOverlayControllerTests { + @Test func overlayControllerLifecycleWithoutUI() { + let controller = VoiceWakeOverlayController(enableUI: false) + let token = controller.startSession( + source: .wakeWord, + transcript: "hello", + attributed: nil, + forwardEnabled: true, + isFinal: false) + + #expect(controller.snapshot().token == token) + #expect(controller.snapshot().isVisible == true) + + controller.updatePartial(token: token, transcript: "hello world") + #expect(controller.snapshot().text == "hello world") + + controller.updateLevel(token: token, -0.5) + #expect(controller.model.level == 0) + controller.updateLevel(token: token, 2.0) + #expect(controller.model.level == 1) + + controller.dismiss(token: token, reason: .explicit, outcome: .empty) + #expect(controller.snapshot().isVisible == false) + #expect(controller.snapshot().token == nil) + } + + @Test func evaluateTokenDropsMismatchAndNoActive() { + let active = UUID() + #expect(VoiceWakeOverlayController.evaluateToken(active: nil, incoming: active) == .dropNoActive) + #expect(VoiceWakeOverlayController.evaluateToken(active: active, incoming: UUID()) == .dropMismatch) + #expect(VoiceWakeOverlayController.evaluateToken(active: active, incoming: active) == .accept) + #expect(VoiceWakeOverlayController.evaluateToken(active: active, incoming: nil) == .accept) + } +} diff --git a/apps/macos/Tests/ClawdisIPCTests/VoiceWakeOverlayViewSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/VoiceWakeOverlayViewSmokeTests.swift new file mode 100644 index 000000000..00211a38f --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/VoiceWakeOverlayViewSmokeTests.swift @@ -0,0 +1,29 @@ +import SwiftUI +import Testing +@testable import Clawdis + +@Suite(.serialized) +@MainActor +struct VoiceWakeOverlayViewSmokeTests { + @Test func overlayViewBuildsBodyInDisplayMode() { + let controller = VoiceWakeOverlayController(enableUI: false) + _ = controller.startSession(source: .wakeWord, transcript: "hello", forwardEnabled: true) + let view = VoiceWakeOverlayView(controller: controller) + _ = view.body + } + + @Test func overlayViewBuildsBodyInEditingMode() { + let controller = VoiceWakeOverlayController(enableUI: false) + let token = controller.startSession(source: .pushToTalk, transcript: "edit me", forwardEnabled: true) + controller.userBeganEditing() + controller.updateLevel(token: token, 0.6) + let view = VoiceWakeOverlayView(controller: controller) + _ = view.body + } + + @Test func closeButtonOverlayBuildsBody() { + let view = CloseButtonOverlay(isVisible: true, onHover: { _ in }, onClose: {}) + _ = view.body + } +} + diff --git a/apps/macos/Tests/ClawdisIPCTests/WebChatServerTests.swift b/apps/macos/Tests/ClawdisIPCTests/WebChatServerTests.swift new file mode 100644 index 000000000..54cd54cd0 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/WebChatServerTests.swift @@ -0,0 +1,112 @@ +import Foundation +import Testing +@testable import Clawdis + +@Suite(.serialized) +struct WebChatServerTests { + private func waitForBaseURL(server: WebChatServer, timeoutSeconds: TimeInterval = 2.0) async throws -> URL { + let deadline = Date().addingTimeInterval(timeoutSeconds) + while Date() < deadline { + if let url = server.baseURL() { return url } + try await Task.sleep(nanoseconds: 25_000_000) // 25ms + } + throw NSError(domain: "WebChatServerTests", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "server did not become ready", + ]) + } + + private func request(_ method: String, url: URL) async throws -> (status: Int, data: Data, headers: [AnyHashable: Any]) { + var req = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 2) + req.httpMethod = method + let config = URLSessionConfiguration.ephemeral + config.waitsForConnectivity = false + let session = URLSession(configuration: config) + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse else { + throw NSError(domain: "WebChatServerTests", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "expected HTTPURLResponse", + ]) + } + return (status: http.statusCode, data: data, headers: http.allHeaderFields) + } + + @Test func servesIndexAtWebChatRoot() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("clawdis-webchat-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + try Data("ok".utf8).write(to: root.appendingPathComponent("index.html")) + + let server = WebChatServer() + server.start(root: root, preferredPort: nil) + defer { server.stop() } + + let base = try await waitForBaseURL(server: server) + let res = try await request("GET", url: base) + #expect(res.status == 200) + #expect(String(data: res.data, encoding: .utf8)?.contains("ok") == true) + } + + @Test func headOmitsBody() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("clawdis-webchat-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + try Data("hello".utf8).write(to: root.appendingPathComponent("asset.txt")) + + let server = WebChatServer() + server.start(root: root, preferredPort: nil) + defer { server.stop() } + + let base = try await waitForBaseURL(server: server) + let url = URL(string: "asset.txt", relativeTo: base)! + let head = try await request("HEAD", url: url) + #expect(head.status == 200) + #expect(head.data.isEmpty == true) + #expect((head.headers["Content-Length"] as? String) == "5") + } + + @Test func returns404ForMissing() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("clawdis-webchat-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + try Data("ok".utf8).write(to: root.appendingPathComponent("index.html")) + + let server = WebChatServer() + server.start(root: root, preferredPort: nil) + defer { server.stop() } + + let base = try await waitForBaseURL(server: server) + let url = URL(string: "missing.txt", relativeTo: base)! + let res = try await request("GET", url: url) + #expect(res.status == 404) + } + + @Test func forbidsTraversalOutsideRoot() async throws { + let tmp = FileManager.default.temporaryDirectory + let root = tmp.appendingPathComponent("clawdis-webchat-test-root-\(UUID().uuidString)") + let outside = tmp.appendingPathComponent("clawdis-webchat-test-outside-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: root) + try? FileManager.default.removeItem(at: outside) + } + + try Data("ok".utf8).write(to: root.appendingPathComponent("index.html")) + try Data("secret".utf8).write(to: outside.appendingPathComponent("secret.txt")) + + let server = WebChatServer() + server.start(root: root, preferredPort: nil) + defer { server.stop() } + + let base = try await waitForBaseURL(server: server) + // Avoid `URL` normalizing away the `/webchat/../` segment by setting the encoded path directly. + var comps = URLComponents(url: base, resolvingAgainstBaseURL: false)! + comps.percentEncodedPath = "/webchat/../\(outside.lastPathComponent)/secret.txt" + let url = comps.url! + let res = try await request("GET", url: url) + #expect(res.status == 403) + } +} diff --git a/apps/macos/Tests/ClawdisIPCTests/WebChatSwiftUISmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/WebChatSwiftUISmokeTests.swift new file mode 100644 index 000000000..02b641454 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/WebChatSwiftUISmokeTests.swift @@ -0,0 +1,60 @@ +import AppKit +import ClawdisChatUI +import Foundation +import Testing +@testable import Clawdis + +@Suite(.serialized) +@MainActor +struct WebChatSwiftUISmokeTests { + private struct TestTransport: ClawdisChatTransport, Sendable { + func requestHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload { + let json = """ + {"sessionKey":"\(sessionKey)","sessionId":null,"messages":[],"thinkingLevel":"off"} + """ + return try JSONDecoder().decode(ClawdisChatHistoryPayload.self, from: Data(json.utf8)) + } + + func sendMessage( + sessionKey _: String, + message _: String, + thinking _: String, + idempotencyKey _: String, + attachments _: [ClawdisChatAttachmentPayload]) async throws -> ClawdisChatSendResponse + { + let json = """ + {"runId":"\(UUID().uuidString)","status":"ok"} + """ + return try JSONDecoder().decode(ClawdisChatSendResponse.self, from: Data(json.utf8)) + } + + func requestHealth(timeoutMs _: Int) async throws -> Bool { true } + + func events() -> AsyncStream { + AsyncStream { continuation in + continuation.finish() + } + } + + func setActiveSessionKey(_: String) async throws {} + } + + @Test func windowControllerShowAndClose() { + let controller = WebChatSwiftUIWindowController( + sessionKey: "main", + presentation: .window, + transport: TestTransport()) + controller.show() + controller.close() + } + + @Test func panelControllerPresentAndClose() { + let anchor = { NSRect(x: 200, y: 400, width: 40, height: 40) } + let controller = WebChatSwiftUIWindowController( + sessionKey: "main", + presentation: .panel(anchorProvider: anchor), + transport: TestTransport()) + controller.presentAnchored(anchorProvider: anchor) + controller.close() + } +} diff --git a/apps/macos/Tests/ClawdisIPCTests/WebChatTunnelTests.swift b/apps/macos/Tests/ClawdisIPCTests/WebChatTunnelTests.swift index 1fd5592a0..39cf802c9 100644 --- a/apps/macos/Tests/ClawdisIPCTests/WebChatTunnelTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/WebChatTunnelTests.swift @@ -3,6 +3,7 @@ import Testing #if canImport(Darwin) import Darwin +import Foundation @Suite struct WebChatTunnelTests { @Test func portIsFreeDetectsIPv4Listener() { @@ -46,7 +47,19 @@ import Darwin _ = Darwin.close(fd) fd = -1 - #expect(WebChatTunnel._testPortIsFree(port) == true) + + // In parallel test runs, another test may briefly grab the same ephemeral port. + // Poll for a short window to avoid flakiness. + let deadline = Date().addingTimeInterval(0.5) + var free = false + while Date() < deadline { + if WebChatTunnel._testPortIsFree(port) { + free = true + break + } + usleep(10_000) // 10ms + } + #expect(free == true) } } #endif diff --git a/apps/macos/Tests/ClawdisIPCTests/WebChatWindowSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/WebChatWindowSmokeTests.swift new file mode 100644 index 000000000..6c4499874 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/WebChatWindowSmokeTests.swift @@ -0,0 +1,133 @@ +import AppKit +import Foundation +import Testing +@testable import Clawdis + +@Suite(.serialized) +@MainActor +struct WebChatWindowSmokeTests { + private struct DefaultsSnapshot { + var connectionMode: Any? + var webChatPort: Any? + var webChatEnabled: Any? + var webChatSwiftUIEnabled: Any? + + init() { + let d = UserDefaults.standard + self.connectionMode = d.object(forKey: connectionModeKey) + self.webChatPort = d.object(forKey: webChatPortKey) + self.webChatEnabled = d.object(forKey: webChatEnabledKey) + self.webChatSwiftUIEnabled = d.object(forKey: webChatSwiftUIEnabledKey) + } + + func restore() { + let d = UserDefaults.standard + if let connectionMode { d.set(connectionMode, forKey: connectionModeKey) } else { d.removeObject(forKey: connectionModeKey) } + if let webChatPort { d.set(webChatPort, forKey: webChatPortKey) } else { d.removeObject(forKey: webChatPortKey) } + if let webChatEnabled { d.set(webChatEnabled, forKey: webChatEnabledKey) } else { d.removeObject(forKey: webChatEnabledKey) } + if let webChatSwiftUIEnabled { d.set(webChatSwiftUIEnabled, forKey: webChatSwiftUIEnabledKey) } else { d.removeObject(forKey: webChatSwiftUIEnabledKey) } + } + } + + private func waitForBaseURL(server: WebChatServer, timeoutSeconds: TimeInterval = 2.0) async throws -> URL { + let deadline = Date().addingTimeInterval(timeoutSeconds) + while Date() < deadline { + if let url = server.baseURL() { return url } + try await Task.sleep(nanoseconds: 25_000_000) // 25ms + } + throw NSError(domain: "WebChatWindowSmokeTests", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "server did not become ready", + ]) + } + + private func makeLocalHTTPServerWithIndex(booted: Bool) async throws -> (server: WebChatServer, port: Int, root: URL) { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("clawdis-webchat-win-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let html = booted + ? "
" + : "
" + try Data(html.utf8).write(to: root.appendingPathComponent("index.html")) + + let server = WebChatServer() + server.start(root: root, preferredPort: nil) + let base = try await waitForBaseURL(server: server) + guard let port = base.port else { + throw NSError(domain: "WebChatWindowSmokeTests", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "server baseURL missing port", + ]) + } + return (server: server, port: port, root: root) + } + + @Test func windowControllerBootstrapsInLocalModeWhenReachable() async throws { + let snapshot = DefaultsSnapshot() + defer { snapshot.restore() } + + let serverInfo = try await makeLocalHTTPServerWithIndex(booted: true) + defer { + serverInfo.server.stop() + try? FileManager.default.removeItem(at: serverInfo.root) + } + + let d = UserDefaults.standard + d.set("local", forKey: connectionModeKey) + d.set(true, forKey: webChatEnabledKey) + d.set(serverInfo.port, forKey: webChatPortKey) + d.set(false, forKey: webChatSwiftUIEnabledKey) + + let controller = WebChatWindowController(sessionKey: "main", presentation: .window) + try await Task.sleep(nanoseconds: 150_000_000) // allow bootstrap + reachability + controller.shutdown() + controller.close() + } + + @Test func panelControllerCanPresentAndDismiss() async throws { + let snapshot = DefaultsSnapshot() + defer { snapshot.restore() } + + let serverInfo = try await makeLocalHTTPServerWithIndex(booted: true) + defer { + serverInfo.server.stop() + try? FileManager.default.removeItem(at: serverInfo.root) + } + + let d = UserDefaults.standard + d.set("local", forKey: connectionModeKey) + d.set(true, forKey: webChatEnabledKey) + d.set(serverInfo.port, forKey: webChatPortKey) + + let controller = WebChatWindowController( + sessionKey: "main", + presentation: .panel(anchorProvider: { NSRect(x: 200, y: 400, width: 40, height: 40) })) + + controller.presentAnchoredPanel(anchorProvider: { NSRect(x: 200, y: 400, width: 40, height: 40) }) + controller.windowDidResignKey(Notification(name: NSWindow.didResignKeyNotification)) + controller.windowWillClose(Notification(name: NSWindow.willCloseNotification)) + controller.shutdown() + controller.close() + } + + @Test func managerShowAndTogglePanelDoNotCrash() async throws { + let snapshot = DefaultsSnapshot() + defer { snapshot.restore() } + + let serverInfo = try await makeLocalHTTPServerWithIndex(booted: true) + defer { + serverInfo.server.stop() + try? FileManager.default.removeItem(at: serverInfo.root) + } + + let d = UserDefaults.standard + d.set("local", forKey: connectionModeKey) + d.set(true, forKey: webChatEnabledKey) + d.set(false, forKey: webChatSwiftUIEnabledKey) + d.set(serverInfo.port, forKey: webChatPortKey) + + WebChatManager.shared.resetTunnels() + WebChatManager.shared.show(sessionKey: "main") + WebChatManager.shared.togglePanel(sessionKey: "main", anchorProvider: { NSRect(x: 220, y: 380, width: 20, height: 20) }) + WebChatManager.shared.togglePanel(sessionKey: "main", anchorProvider: { NSRect(x: 220, y: 380, width: 20, height: 20) }) + WebChatManager.shared.close() + } +} + diff --git a/apps/macos/Tests/ClawdisIPCTests/WindowPlacementTests.swift b/apps/macos/Tests/ClawdisIPCTests/WindowPlacementTests.swift new file mode 100644 index 000000000..afe60b0b7 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/WindowPlacementTests.swift @@ -0,0 +1,85 @@ +import AppKit +import Testing +@testable import Clawdis + +@Suite +@MainActor +struct WindowPlacementTests { + @Test + func centeredFrameZeroBoundsFallsBackToOrigin() { + let frame = WindowPlacement.centeredFrame(size: NSSize(width: 120, height: 80), in: NSRect.zero) + #expect(frame.origin == .zero) + #expect(frame.size == NSSize(width: 120, height: 80)) + } + + @Test + func centeredFrameClampsToBoundsAndCenters() { + let bounds = NSRect(x: 10, y: 20, width: 300, height: 200) + let frame = WindowPlacement.centeredFrame(size: NSSize(width: 600, height: 120), in: bounds) + #expect(frame.size.width == bounds.width) + #expect(frame.size.height == 120) + #expect(frame.minX == bounds.minX) + #expect(frame.midY == bounds.midY) + } + + @Test + func topRightFrameZeroBoundsFallsBackToOrigin() { + let frame = WindowPlacement.topRightFrame( + size: NSSize(width: 120, height: 80), + padding: 12, + in: NSRect.zero) + #expect(frame.origin == .zero) + #expect(frame.size == NSSize(width: 120, height: 80)) + } + + @Test + func topRightFrameClampsToBoundsAndAppliesPadding() { + let bounds = NSRect(x: 10, y: 20, width: 300, height: 200) + let frame = WindowPlacement.topRightFrame( + size: NSSize(width: 400, height: 50), + padding: 8, + in: bounds) + #expect(frame.size.width == bounds.width) + #expect(frame.size.height == 50) + #expect(frame.maxX == bounds.maxX - 8) + #expect(frame.maxY == bounds.maxY - 8) + } + + @Test + func ensureOnScreenUsesFallbackWhenWindowOffscreen() { + let window = NSWindow( + contentRect: NSRect(x: 100_000, y: 100_000, width: 200, height: 120), + styleMask: [.borderless], + backing: .buffered, + defer: false) + + WindowPlacement.ensureOnScreen( + window: window, + defaultSize: NSSize(width: 200, height: 120), + fallback: { _ in NSRect(x: 11, y: 22, width: 33, height: 44) }) + + #expect(window.frame == NSRect(x: 11, y: 22, width: 33, height: 44)) + } + + @Test + func ensureOnScreenDoesNotMoveVisibleWindow() { + let screen = NSScreen.main ?? NSScreen.screens.first + #expect(screen != nil) + guard let screen else { return } + + let visible = screen.visibleFrame.insetBy(dx: 40, dy: 40) + let window = NSWindow( + contentRect: NSRect(x: visible.minX, y: visible.minY, width: 200, height: 120), + styleMask: [.titled], + backing: .buffered, + defer: false) + let original = window.frame + + WindowPlacement.ensureOnScreen( + window: window, + defaultSize: NSSize(width: 200, height: 120), + fallback: { _ in NSRect(x: 11, y: 22, width: 33, height: 44) }) + + #expect(window.frame == original) + } +}