test(macos): boost Clawdis coverage to 40%

This commit is contained in:
Peter Steinberger
2025-12-14 04:30:45 +00:00
parent 845b26a73b
commit 3ef910d23e
26 changed files with 1089 additions and 20 deletions

View File

@@ -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?

View File

@@ -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?

View File

@@ -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"

View File

@@ -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

View File

@@ -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 systems 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

View File

@@ -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,

View File

@@ -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)
}

View 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)
}
}

View 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)
}
}

View File

@@ -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()
}
}

View File

@@ -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")
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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
}())
}
}

View File

@@ -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
}
}

View 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
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View 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)
}
}

View File

@@ -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()
}
}

View File

@@ -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

View 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()
}
}

View 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)
}
}