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 text: String
|
||||||
let tint: Color
|
let tint: Color
|
||||||
|
|
||||||
@@ -488,7 +488,7 @@ private struct StatusPill: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct CronJobEditor: View {
|
struct CronJobEditor: View {
|
||||||
let job: CronJob?
|
let job: CronJob?
|
||||||
@Binding var isSaving: Bool
|
@Binding var isSaving: Bool
|
||||||
@Binding var error: String?
|
@Binding var error: String?
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ struct DebugSettings: View {
|
|||||||
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
|
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
|
||||||
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
|
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
|
||||||
@AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue
|
@AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue
|
||||||
|
@AppStorage(canvasEnabledKey) private var canvasEnabled: Bool = true
|
||||||
@State private var modelsCount: Int?
|
@State private var modelsCount: Int?
|
||||||
@State private var modelsLoading = false
|
@State private var modelsLoading = false
|
||||||
@State private var modelsError: String?
|
@State private var modelsError: String?
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ struct SettingsRootView: View {
|
|||||||
enum SettingsTab: CaseIterable {
|
enum SettingsTab: CaseIterable {
|
||||||
case general, tools, sessions, cron, config, instances, voiceWake, permissions, debug, about
|
case general, tools, sessions, cron, config, instances, voiceWake, permissions, debug, about
|
||||||
static let windowWidth: CGFloat = 658 // +10% (tabs fit better)
|
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 {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .general: "General"
|
case .general: "General"
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ extension ProcessInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var isRunningTests: Bool {
|
var isRunningTests: Bool {
|
||||||
// SwiftPM test bundles are typically loaded from a `.xctest` bundle.
|
// SwiftPM tests load one or more `.xctest` bundles. With Swift Testing, `Bundle.main` is not
|
||||||
// We also check common XCTest env vars because some runners still set them.
|
// 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 }
|
if Bundle.main.bundleURL.pathExtension == "xctest" { return true }
|
||||||
|
|
||||||
|
// Backwards-compatible fallbacks for runners that still set XCTest env vars.
|
||||||
return self.environment["XCTestConfigurationFilePath"] != nil
|
return self.environment["XCTestConfigurationFilePath"] != nil
|
||||||
|| self.environment["XCTestBundlePath"] != nil
|
|| self.environment["XCTestBundlePath"] != nil
|
||||||
|| self.environment["XCTestSessionIdentifier"] != nil
|
|| self.environment["XCTestSessionIdentifier"] != nil
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ final class VoiceWakeOverlayController: ObservableObject {
|
|||||||
static let shared = VoiceWakeOverlayController()
|
static let shared = VoiceWakeOverlayController()
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.overlay")
|
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.
|
/// 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`.)
|
/// (Menu bar menus typically live at `.popUpMenu`.)
|
||||||
@@ -47,6 +48,10 @@ final class VoiceWakeOverlayController: ObservableObject {
|
|||||||
private let minHeight: CGFloat = 48
|
private let minHeight: CGFloat = 48
|
||||||
let closeOverflow: CGFloat = 10
|
let closeOverflow: CGFloat = 10
|
||||||
|
|
||||||
|
init(enableUI: Bool = true) {
|
||||||
|
self.enableUI = enableUI
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func startSession(
|
func startSession(
|
||||||
token: UUID = UUID(),
|
token: UUID = UUID(),
|
||||||
@@ -207,7 +212,23 @@ final class VoiceWakeOverlayController: ObservableObject {
|
|||||||
self.autoSendTask?.cancel(); self.autoSendToken = nil
|
self.autoSendTask?.cancel(); self.autoSendToken = nil
|
||||||
self.model.isSending = false
|
self.model.isSending = false
|
||||||
self.model.isEditing = 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)
|
let target = self.dismissTargetFrame(for: window.frame, reason: reason, outcome: outcome)
|
||||||
NSAnimationContext.runAnimationGroup { context in
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
context.duration = 0.18
|
context.duration = 0.18
|
||||||
@@ -273,6 +294,12 @@ final class VoiceWakeOverlayController: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func present() {
|
private func present() {
|
||||||
|
if !self.enableUI || ProcessInfo.processInfo.isRunningTests {
|
||||||
|
if !self.model.isVisible {
|
||||||
|
self.model.isVisible = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
self.ensureWindow()
|
self.ensureWindow()
|
||||||
self.hostingView?.rootView = VoiceWakeOverlayView(controller: self)
|
self.hostingView?.rootView = VoiceWakeOverlayView(controller: self)
|
||||||
let target = self.targetFrame()
|
let target = self.targetFrame()
|
||||||
@@ -437,7 +464,7 @@ final class VoiceWakeOverlayController: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct VoiceWakeOverlayView: View {
|
struct VoiceWakeOverlayView: View {
|
||||||
@ObservedObject var controller: VoiceWakeOverlayController
|
@ObservedObject var controller: VoiceWakeOverlayController
|
||||||
@FocusState private var textFocused: Bool
|
@FocusState private var textFocused: Bool
|
||||||
@State private var isHovering: Bool = false
|
@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
|
@Binding var text: String
|
||||||
var attributed: NSAttributedString
|
var attributed: NSAttributedString
|
||||||
var isFinal: Bool
|
var isFinal: Bool
|
||||||
@@ -651,7 +678,7 @@ private struct TranscriptTextView: NSViewRepresentable {
|
|||||||
|
|
||||||
// MARK: - Vibrant display label
|
// MARK: - Vibrant display label
|
||||||
|
|
||||||
private struct VibrantLabelView: NSViewRepresentable {
|
struct VibrantLabelView: NSViewRepresentable {
|
||||||
var attributed: NSAttributedString
|
var attributed: NSAttributedString
|
||||||
var onTap: () -> Void
|
var onTap: () -> Void
|
||||||
|
|
||||||
@@ -708,7 +735,7 @@ private final class ClickCatcher: NSView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct CloseHoverButton: View {
|
struct CloseHoverButton: View {
|
||||||
var onClose: () -> Void
|
var onClose: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -728,7 +755,7 @@ private struct CloseHoverButton: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct CloseButtonOverlay: View {
|
struct CloseButtonOverlay: View {
|
||||||
var isVisible: Bool
|
var isVisible: Bool
|
||||||
var onHover: (Bool) -> Void
|
var onHover: (Bool) -> Void
|
||||||
var onClose: () -> 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.
|
/// Returns the base URL once the server is ready, otherwise nil.
|
||||||
func baseURL() -> URL? {
|
func baseURL() -> URL? {
|
||||||
var url: URL?
|
var url: URL?
|
||||||
@@ -174,8 +183,10 @@ final class WebChatServer: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
let fileURL = root.appendingPathComponent(path)
|
let fileURL = root.appendingPathComponent(path)
|
||||||
webChatServerLogger.debug("WebChatServer resolved file=\(fileURL.path, privacy: .public)")
|
webChatServerLogger.debug("WebChatServer resolved file=\(fileURL.path, privacy: .public)")
|
||||||
// Simple directory traversal guard: served files must live under the bundled web root.
|
// Directory traversal + symlink escape guard: served files must resolve under the bundled web root.
|
||||||
guard fileURL.path.hasPrefix(root.path) else {
|
let rootPath = root.standardizedFileURL.resolvingSymlinksInPath().path
|
||||||
|
let resolvedPath = fileURL.standardizedFileURL.resolvingSymlinksInPath().path
|
||||||
|
guard resolvedPath == rootPath || resolvedPath.hasPrefix(rootPath + "/") else {
|
||||||
let forbidden = Data("Forbidden".utf8)
|
let forbidden = Data("Forbidden".utf8)
|
||||||
self.send(
|
self.send(
|
||||||
status: 403,
|
status: 403,
|
||||||
|
|||||||
@@ -4,6 +4,19 @@ import AppKit
|
|||||||
enum WindowPlacement {
|
enum WindowPlacement {
|
||||||
static func centeredFrame(size: NSSize, on screen: NSScreen? = NSScreen.main) -> NSRect {
|
static func centeredFrame(size: NSSize, on screen: NSScreen? = NSScreen.main) -> NSRect {
|
||||||
let bounds = (screen?.visibleFrame ?? NSScreen.screens.first?.visibleFrame ?? .zero)
|
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 {
|
if bounds == .zero {
|
||||||
return NSRect(origin: .zero, size: size)
|
return NSRect(origin: .zero, size: size)
|
||||||
}
|
}
|
||||||
@@ -16,12 +29,7 @@ enum WindowPlacement {
|
|||||||
return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight)
|
return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func topRightFrame(
|
static func topRightFrame(size: NSSize, padding: CGFloat, in bounds: NSRect) -> NSRect {
|
||||||
size: NSSize,
|
|
||||||
padding: CGFloat,
|
|
||||||
on screen: NSScreen? = NSScreen.main) -> NSRect
|
|
||||||
{
|
|
||||||
let bounds = (screen?.visibleFrame ?? NSScreen.screens.first?.visibleFrame ?? .zero)
|
|
||||||
if bounds == .zero {
|
if bounds == .zero {
|
||||||
return NSRect(origin: .zero, size: size)
|
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)
|
#if canImport(Darwin)
|
||||||
import Darwin
|
import Darwin
|
||||||
|
import Foundation
|
||||||
|
|
||||||
@Suite struct WebChatTunnelTests {
|
@Suite struct WebChatTunnelTests {
|
||||||
@Test func portIsFreeDetectsIPv4Listener() {
|
@Test func portIsFreeDetectsIPv4Listener() {
|
||||||
@@ -46,7 +47,19 @@ import Darwin
|
|||||||
|
|
||||||
_ = Darwin.close(fd)
|
_ = Darwin.close(fd)
|
||||||
fd = -1
|
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
|
#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