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