test(macos): boost Clawdis coverage to 40%
This commit is contained in:
@@ -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?
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
43
apps/macos/Tests/ClawdisIPCTests/AgentEventStoreTests.swift
Normal file
43
apps/macos/Tests/ClawdisIPCTests/AgentEventStoreTests.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
45
apps/macos/Tests/ClawdisIPCTests/AgentWorkspaceTests.swift
Normal file
45
apps/macos/Tests/ClawdisIPCTests/AgentWorkspaceTests.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
22
apps/macos/Tests/ClawdisIPCTests/MenuContentSmokeTests.swift
Normal file
22
apps/macos/Tests/ClawdisIPCTests/MenuContentSmokeTests.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
112
apps/macos/Tests/ClawdisIPCTests/WebChatServerTests.swift
Normal file
112
apps/macos/Tests/ClawdisIPCTests/WebChatServerTests.swift
Normal file
@@ -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("<html>ok</html>".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("<html>ok</html>".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("<html>ok</html>".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)
|
||||
}
|
||||
}
|
||||
@@ -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<ClawdisChatTransportEvent> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
133
apps/macos/Tests/ClawdisIPCTests/WebChatWindowSmokeTests.swift
Normal file
133
apps/macos/Tests/ClawdisIPCTests/WebChatWindowSmokeTests.swift
Normal file
@@ -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
|
||||
? "<html><body><div id='app' data-booted='1'></div></body></html>"
|
||||
: "<html><body><div id='app'></div></body></html>"
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
85
apps/macos/Tests/ClawdisIPCTests/WindowPlacementTests.swift
Normal file
85
apps/macos/Tests/ClawdisIPCTests/WindowPlacementTests.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user