From deec315f6ac5e6b8c4b1995aeb30f61e9a5c7447 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 24 Dec 2025 17:42:14 +0100 Subject: [PATCH] test: expand settings coverage --- .../Clawdis/AnthropicAuthControls.swift | 34 ++++- apps/macos/Sources/Clawdis/CronSettings.swift | 92 ++++++++++++++ .../Sources/Clawdis/GeneralSettings.swift | 38 ++++++ .../Sources/Clawdis/InstancesSettings.swift | 96 ++++++++++++++ .../Sources/Clawdis/SkillsSettings.swift | 54 +++++++- .../Clawdis/TailscaleIntegrationSection.swift | 76 ++++++++--- .../Sources/Clawdis/TailscaleService.swift | 16 +++ .../AnthropicAuthControlsSmokeTests.swift | 29 +++++ .../AnthropicAuthResolverTests.swift | 15 +-- .../ConnectionsSettingsSmokeTests.swift | 100 +++++++++++++++ .../CronJobEditorSmokeTests.swift | 11 +- .../InstancesSettingsSmokeTests.swift | 59 +++++++++ .../SettingsViewSmokeTests.swift | 12 +- .../SkillsSettingsSmokeTests.swift | 118 ++++++++++++++++++ .../TailscaleIntegrationSectionTests.swift | 48 +++++++ 15 files changed, 766 insertions(+), 32 deletions(-) create mode 100644 apps/macos/Tests/ClawdisIPCTests/AnthropicAuthControlsSmokeTests.swift create mode 100644 apps/macos/Tests/ClawdisIPCTests/ConnectionsSettingsSmokeTests.swift create mode 100644 apps/macos/Tests/ClawdisIPCTests/InstancesSettingsSmokeTests.swift create mode 100644 apps/macos/Tests/ClawdisIPCTests/SkillsSettingsSmokeTests.swift create mode 100644 apps/macos/Tests/ClawdisIPCTests/TailscaleIntegrationSectionTests.swift diff --git a/apps/macos/Sources/Clawdis/AnthropicAuthControls.swift b/apps/macos/Sources/Clawdis/AnthropicAuthControls.swift index 2b688fcc2..c36edc2a5 100644 --- a/apps/macos/Sources/Clawdis/AnthropicAuthControls.swift +++ b/apps/macos/Sources/Clawdis/AnthropicAuthControls.swift @@ -15,7 +15,14 @@ struct AnthropicAuthControls: View { @State private var autoConnectClipboard = true @State private var lastPasteboardChangeCount = NSPasteboard.general.changeCount - private static let clipboardPoll = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect() + private static let clipboardPoll: AnyPublisher = { + if ProcessInfo.processInfo.isRunningTests { + return Empty(completeImmediately: false).eraseToAnyPublisher() + } + return Timer.publish(every: 0.4, on: .main, in: .common) + .autoconnect() + .eraseToAnyPublisher() + }() var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -200,3 +207,28 @@ struct AnthropicAuthControls: View { Task { await self.finishOAuth() } } } + +#if DEBUG +extension AnthropicAuthControls { + init( + connectionMode: AppState.ConnectionMode, + oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus, + pkce: AnthropicOAuth.PKCE? = nil, + code: String = "", + busy: Bool = false, + statusText: String? = nil, + autoDetectClipboard: Bool = true, + autoConnectClipboard: Bool = true) + { + self.connectionMode = connectionMode + self._oauthStatus = State(initialValue: oauthStatus) + self._pkce = State(initialValue: pkce) + self._code = State(initialValue: code) + self._busy = State(initialValue: busy) + self._statusText = State(initialValue: statusText) + self._autoDetectClipboard = State(initialValue: autoDetectClipboard) + self._autoConnectClipboard = State(initialValue: autoConnectClipboard) + self._lastPasteboardChangeCount = State(initialValue: NSPasteboard.general.changeCount) + } +} +#endif diff --git a/apps/macos/Sources/Clawdis/CronSettings.swift b/apps/macos/Sources/Clawdis/CronSettings.swift index 6ba806a79..a82ab95e2 100644 --- a/apps/macos/Sources/Clawdis/CronSettings.swift +++ b/apps/macos/Sources/Clawdis/CronSettings.swift @@ -1095,4 +1095,96 @@ struct CronSettings_Previews: PreviewProvider { .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) } } + +@MainActor +extension CronSettings { + static func exerciseForTesting() { + let store = CronJobsStore(isPreview: true) + store.schedulerEnabled = false + store.schedulerStorePath = "/tmp/clawdis-cron-store.json" + + let job = CronJob( + id: "job-1", + name: "Daily summary", + description: "Summary job", + enabled: true, + createdAtMs: 1_700_000_000_000, + updatedAtMs: 1_700_000_100_000, + schedule: .cron(expr: "0 8 * * *", tz: "UTC"), + sessionTarget: .isolated, + wakeMode: .nextHeartbeat, + payload: .agentTurn( + message: "Summarize", + thinking: "low", + timeoutSeconds: 120, + deliver: true, + channel: "whatsapp", + to: "+15551234567", + bestEffortDeliver: true), + isolation: CronIsolation(postToMainPrefix: "[cron] "), + state: CronJobState( + nextRunAtMs: 1_700_000_200_000, + runningAtMs: nil, + lastRunAtMs: 1_700_000_050_000, + lastStatus: "ok", + lastError: nil, + lastDurationMs: 1200)) + + let run = CronRunLogEntry( + ts: 1_700_000_050_000, + jobId: job.id, + action: "finished", + status: "ok", + error: nil, + summary: "done", + runAtMs: 1_700_000_050_000, + durationMs: 1200, + nextRunAtMs: 1_700_000_200_000) + + store.jobs = [job] + store.selectedJobId = job.id + store.runEntries = [run] + + var view = CronSettings(store: store) + _ = view.body + _ = view.jobRow(job) + _ = view.jobContextMenu(job) + _ = view.detailHeader(job) + _ = view.detailCard(job) + _ = view.runHistoryCard(job) + _ = view.runRow(run) + _ = view.payloadSummary(job.payload) + _ = view.scheduleSummary(job.schedule) + _ = view.statusTint(job.state.lastStatus) + _ = view.nextRunLabel(Date()) + _ = view.formatDuration(ms: 1234) + } +} + +extension CronJobEditor { + mutating func exerciseForTesting() { + self.name = "Test job" + self.description = "Test description" + self.enabled = true + self.sessionTarget = .isolated + self.wakeMode = .now + + self.scheduleKind = .every + self.everyText = "15m" + + self.payloadKind = .agentTurn + self.agentMessage = "Run diagnostic" + self.deliver = true + self.channel = .last + self.to = "+15551230000" + self.thinking = "low" + self.timeoutSeconds = "90" + self.bestEffortDeliver = true + self.postPrefix = "Cron" + + _ = self.buildAgentTurnPayload() + _ = try? self.buildPayload() + _ = self.formatDuration(ms: 45000) + } +} #endif diff --git a/apps/macos/Sources/Clawdis/GeneralSettings.swift b/apps/macos/Sources/Clawdis/GeneralSettings.swift index 6cb504d2a..5ef7f277b 100644 --- a/apps/macos/Sources/Clawdis/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdis/GeneralSettings.swift @@ -651,4 +651,42 @@ struct GeneralSettings_Previews: PreviewProvider { .environment(TailscaleService.shared) } } + +@MainActor +extension GeneralSettings { + static func exerciseForTesting() { + let state = AppState(preview: true) + state.connectionMode = .remote + state.remoteTarget = "user@host:2222" + state.remoteIdentity = "/tmp/id_ed25519" + state.remoteProjectRoot = "/tmp/clawdis" + state.remoteCliPath = "/tmp/clawdis" + + var view = GeneralSettings(state: state) + view.gatewayStatus = GatewayEnvironmentStatus( + kind: .ok, + nodeVersion: "1.0.0", + gatewayVersion: "1.0.0", + requiredGateway: nil, + message: "Gateway ready") + view.remoteStatus = .failed("SSH failed") + view.showRemoteAdvanced = true + view.cliInstalled = true + view.cliInstallLocation = "/usr/local/bin/clawdis" + view.cliStatus = "Installed" + _ = view.body + + state.connectionMode = .unconfigured + _ = view.body + + state.connectionMode = .local + view.gatewayStatus = GatewayEnvironmentStatus( + kind: .error("Gateway offline"), + nodeVersion: nil, + gatewayVersion: nil, + requiredGateway: nil, + message: "Gateway offline") + _ = view.body + } +} #endif diff --git a/apps/macos/Sources/Clawdis/InstancesSettings.swift b/apps/macos/Sources/Clawdis/InstancesSettings.swift index 69fac6667..aa748ad28 100644 --- a/apps/macos/Sources/Clawdis/InstancesSettings.swift +++ b/apps/macos/Sources/Clawdis/InstancesSettings.swift @@ -346,6 +346,102 @@ struct InstancesSettings: View { } #if DEBUG +extension InstancesSettings { + static func exerciseForTesting() { + let view = InstancesSettings(store: InstancesStore(isPreview: true)) + let mac = InstanceInfo( + id: "mac", + host: "studio", + ip: "10.0.0.2", + version: "1.2.3", + platform: "macOS 14.2", + deviceFamily: "Mac", + modelIdentifier: "Mac14,10", + lastInputSeconds: 12, + mode: "local", + reason: "self", + text: "Mac Studio", + ts: 1_700_000_000_000) + let genericIOS = InstanceInfo( + id: "iphone", + host: "phone", + ip: "10.0.0.3", + version: "2.0.0", + platform: "iOS 17.2", + deviceFamily: "iPhone", + modelIdentifier: nil, + lastInputSeconds: 35, + mode: "node", + reason: "connect", + text: "iPhone node", + ts: 1_700_000_100_000) + let android = InstanceInfo( + id: "android", + host: "pixel", + ip: nil, + version: "3.1.0", + platform: "Android 14", + deviceFamily: "Android", + modelIdentifier: nil, + lastInputSeconds: 90, + mode: "node", + reason: "seq gap", + text: "Android node", + ts: 1_700_000_200_000) + let gateway = InstanceInfo( + id: "gateway", + host: "gateway", + ip: "10.0.0.9", + version: "4.0.0", + platform: "Linux", + deviceFamily: nil, + modelIdentifier: nil, + lastInputSeconds: nil, + mode: "gateway", + reason: "periodic", + text: "Gateway", + ts: 1_700_000_300_000) + + _ = view.instanceRow(mac) + _ = view.instanceRow(genericIOS) + _ = view.instanceRow(android) + _ = view.instanceRow(gateway) + + _ = view.leadingDeviceSymbol( + mac, + device: DevicePresentation(title: "Mac Studio", symbol: "macstudio")) + _ = view.leadingDeviceSymbol( + mac, + device: DevicePresentation(title: "MacBook Pro", symbol: "laptopcomputer")) + _ = view.leadingDeviceSymbol(android, device: nil) + _ = view.platformIcon("tvOS 17.1") + _ = view.platformIcon("watchOS 10") + _ = view.platformIcon("unknown 1.0") + _ = view.prettyPlatform("macOS 14.2") + _ = view.prettyPlatform("iOS 17") + _ = view.prettyPlatform("ipados 17.1") + _ = view.prettyPlatform("linux") + _ = view.prettyPlatform(" ") + _ = view.parsePlatform("macOS 14.1") + _ = view.parsePlatform(" ") + _ = view.presenceUpdateSourceShortText("self") + _ = view.presenceUpdateSourceShortText("instances-refresh") + _ = view.presenceUpdateSourceShortText("seq gap") + _ = view.presenceUpdateSourceShortText("custom") + _ = view.presenceUpdateSourceShortText(" ") + _ = view.updateSummaryText(mac, isGateway: false) + _ = view.updateSummaryText(gateway, isGateway: true) + _ = view.presenceUpdateSourceHelp("") + _ = view.presenceUpdateSourceHelp("connect") + _ = view.safeSystemSymbol("not-a-symbol", fallback: "cpu") + _ = view.isSystemSymbolAvailable("sparkles") + _ = view.label(icon: "android", text: "Android") + _ = view.label(icon: "sparkles", text: "Sparkles") + _ = view.label(icon: nil, text: "Plain") + _ = AndroidMark().body + } +} + struct InstancesSettings_Previews: PreviewProvider { static var previews: some View { InstancesSettings(store: .preview()) diff --git a/apps/macos/Sources/Clawdis/SkillsSettings.swift b/apps/macos/Sources/Clawdis/SkillsSettings.swift index eccd7e5af..1295814a7 100644 --- a/apps/macos/Sources/Clawdis/SkillsSettings.swift +++ b/apps/macos/Sources/Clawdis/SkillsSettings.swift @@ -8,8 +8,9 @@ struct SkillsSettings: View { @State private var envEditor: EnvEditorState? @State private var filter: SkillsFilter = .all - init(state: AppState = AppStateStore.shared) { + init(state: AppState = AppStateStore.shared, model: SkillsSettingsModel = SkillsSettingsModel()) { self.state = state + self._model = State(initialValue: model) } var body: some View { @@ -571,4 +572,55 @@ struct SkillsSettings_Previews: PreviewProvider { .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) } } + +extension SkillsSettings { + static func exerciseForTesting() { + let skill = SkillStatus( + name: "Test Skill", + description: "Test description", + source: "clawdis-bundled", + filePath: "/tmp/skills/test", + baseDir: "/tmp/skills", + skillKey: "test", + primaryEnv: "API_KEY", + emoji: "๐Ÿงช", + homepage: "https://example.com", + always: false, + disabled: false, + eligible: false, + requirements: SkillRequirements(bins: ["python3"], env: ["API_KEY"], config: ["skills.test"]), + missing: SkillMissing(bins: ["python3"], env: ["API_KEY"], config: ["skills.test"]), + configChecks: [ + SkillStatusConfigCheck(path: "skills.test", value: AnyCodable(false), satisfied: false), + ], + install: [ + SkillInstallOption(id: "brew", kind: "brew", label: "brew install python", bins: ["python3"]), + ]) + + let row = SkillRow( + skill: skill, + isBusy: false, + connectionMode: .remote, + onToggleEnabled: { _ in }, + onInstall: { _, _ in }, + onSetEnv: { _, _ in }) + _ = row.body + + _ = SkillTag(text: "Bundled").body + + let editor = EnvEditorView( + editor: EnvEditorState( + skillKey: "test", + skillName: "Test Skill", + envKey: "API_KEY", + isPrimary: true), + onSave: { _ in }) + _ = editor.body + } + + mutating func setFilterForTesting(_ rawValue: String) { + guard let filter = SkillsFilter(rawValue: rawValue) else { return } + self.filter = filter + } +} #endif diff --git a/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift b/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift index 87e1a211f..54acecdc7 100644 --- a/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift +++ b/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift @@ -32,6 +32,9 @@ struct TailscaleIntegrationSection: View { let isPaused: Bool @Environment(TailscaleService.self) private var tailscaleService +#if DEBUG + private var testingService: TailscaleService? +#endif @State private var hasLoaded = false @State private var tailscaleMode: GatewayTailscaleMode = .off @@ -41,6 +44,22 @@ struct TailscaleIntegrationSection: View { @State private var validationMessage: String? @State private var statusTimer: Timer? + init(connectionMode: AppState.ConnectionMode, isPaused: Bool) { + self.connectionMode = connectionMode + self.isPaused = isPaused +#if DEBUG + self.testingService = nil +#endif + } + + private var effectiveService: TailscaleService { +#if DEBUG + return self.testingService ?? self.tailscaleService +#else + return self.tailscaleService +#endif + } + var body: some View { VStack(alignment: .leading, spacing: 10) { Text("Tailscale (dashboard access)") @@ -48,7 +67,7 @@ struct TailscaleIntegrationSection: View { self.statusRow - if !self.tailscaleService.isInstalled { + if !self.effectiveService.isInstalled { self.installButtons } else { self.modePicker @@ -87,7 +106,7 @@ struct TailscaleIntegrationSection: View { guard !self.hasLoaded else { return } self.hasLoaded = true self.loadConfig() - await self.tailscaleService.checkTailscaleStatus() + await self.effectiveService.checkTailscaleStatus() self.startStatusTimer() } .onDisappear { @@ -110,7 +129,7 @@ struct TailscaleIntegrationSection: View { .font(.callout) Spacer() Button("Refresh") { - Task { await self.tailscaleService.checkTailscaleStatus() } + Task { await self.effectiveService.checkTailscaleStatus() } } .buttonStyle(.bordered) .controlSize(.small) @@ -118,24 +137,24 @@ struct TailscaleIntegrationSection: View { } private var statusColor: Color { - if !self.tailscaleService.isInstalled { return .yellow } - if self.tailscaleService.isRunning { return .green } + if !self.effectiveService.isInstalled { return .yellow } + if self.effectiveService.isRunning { return .green } return .orange } private var statusText: String { - if !self.tailscaleService.isInstalled { return "Tailscale is not installed" } - if self.tailscaleService.isRunning { return "Tailscale is installed and running" } + if !self.effectiveService.isInstalled { return "Tailscale is not installed" } + if self.effectiveService.isRunning { return "Tailscale is installed and running" } return "Tailscale is installed but not running" } private var installButtons: some View { HStack(spacing: 12) { - Button("App Store") { self.tailscaleService.openAppStore() } + Button("App Store") { self.effectiveService.openAppStore() } .buttonStyle(.link) - Button("Direct Download") { self.tailscaleService.openDownloadPage() } + Button("Direct Download") { self.effectiveService.openDownloadPage() } .buttonStyle(.link) - Button("Setup Guide") { self.tailscaleService.openSetupGuide() } + Button("Setup Guide") { self.effectiveService.openSetupGuide() } .buttonStyle(.link) } .controlSize(.small) @@ -159,7 +178,7 @@ struct TailscaleIntegrationSection: View { @ViewBuilder private var accessURLRow: some View { - if let host = self.tailscaleService.tailscaleHostname { + if let host = self.effectiveService.tailscaleHostname { let url = "https://\(host)/ui/" HStack(spacing: 8) { Text("Dashboard URL:") @@ -173,14 +192,14 @@ struct TailscaleIntegrationSection: View { .font(.system(.caption, design: .monospaced)) } } - } else if !self.tailscaleService.isRunning { + } else if !self.effectiveService.isRunning { Text("Start Tailscale to get your tailnet hostname.") .font(.caption) .foregroundStyle(.secondary) } - if self.tailscaleService.isInstalled, !self.tailscaleService.isRunning { - Button("Start Tailscale") { self.tailscaleService.openTailscaleApp() } + if self.effectiveService.isInstalled, !self.effectiveService.isRunning { + Button("Start Tailscale") { self.effectiveService.openTailscaleApp() } .buttonStyle(.borderedProminent) .controlSize(.small) } @@ -304,8 +323,11 @@ struct TailscaleIntegrationSection: View { private func startStatusTimer() { self.stopStatusTimer() + if ProcessInfo.processInfo.isRunningTests { + return + } self.statusTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in - Task { await self.tailscaleService.checkTailscaleStatus() } + Task { await self.effectiveService.checkTailscaleStatus() } } } @@ -314,3 +336,27 @@ struct TailscaleIntegrationSection: View { self.statusTimer = nil } } + +#if DEBUG +extension TailscaleIntegrationSection { + mutating func setTestingState( + mode: String, + requireCredentials: Bool, + password: String = "secret", + statusMessage: String? = nil, + validationMessage: String? = nil) + { + if let mode = GatewayTailscaleMode(rawValue: mode) { + self.tailscaleMode = mode + } + self.requireCredentialsForServe = requireCredentials + self.password = password + self.statusMessage = statusMessage + self.validationMessage = validationMessage + } + + mutating func setTestingService(_ service: TailscaleService?) { + self.testingService = service + } +} +#endif diff --git a/apps/macos/Sources/Clawdis/TailscaleService.swift b/apps/macos/Sources/Clawdis/TailscaleService.swift index f6bf00263..8937bd4f9 100644 --- a/apps/macos/Sources/Clawdis/TailscaleService.swift +++ b/apps/macos/Sources/Clawdis/TailscaleService.swift @@ -36,6 +36,22 @@ final class TailscaleService { Task { await self.checkTailscaleStatus() } } +#if DEBUG + init( + isInstalled: Bool, + isRunning: Bool, + tailscaleHostname: String? = nil, + tailscaleIP: String? = nil, + statusError: String? = nil) + { + self.isInstalled = isInstalled + self.isRunning = isRunning + self.tailscaleHostname = tailscaleHostname + self.tailscaleIP = tailscaleIP + self.statusError = statusError + } +#endif + func checkAppInstallation() -> Bool { let installed = FileManager.default.fileExists(atPath: "/Applications/Tailscale.app") self.logger.info("Tailscale app installed: \(installed)") diff --git a/apps/macos/Tests/ClawdisIPCTests/AnthropicAuthControlsSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/AnthropicAuthControlsSmokeTests.swift new file mode 100644 index 000000000..c485b5e6c --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/AnthropicAuthControlsSmokeTests.swift @@ -0,0 +1,29 @@ +import Testing +@testable import Clawdis + +@Suite(.serialized) +@MainActor +struct AnthropicAuthControlsSmokeTests { + @Test func anthropicAuthControlsBuildsBodyLocal() { + let pkce = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge") + let view = AnthropicAuthControls( + connectionMode: .local, + oauthStatus: .connected(expiresAtMs: 1_700_000_000_000), + pkce: pkce, + code: "code#state", + statusText: "Detected code", + autoDetectClipboard: false, + autoConnectClipboard: false) + _ = view.body + } + + @Test func anthropicAuthControlsBuildsBodyRemote() { + let view = AnthropicAuthControls( + connectionMode: .remote, + oauthStatus: .missingFile, + pkce: nil, + code: "", + statusText: nil) + _ = view.body + } +} diff --git a/apps/macos/Tests/ClawdisIPCTests/AnthropicAuthResolverTests.swift b/apps/macos/Tests/ClawdisIPCTests/AnthropicAuthResolverTests.swift index 941443b05..8d43e81c7 100644 --- a/apps/macos/Tests/ClawdisIPCTests/AnthropicAuthResolverTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/AnthropicAuthResolverTests.swift @@ -6,21 +6,9 @@ import Testing struct AnthropicAuthResolverTests { @Test func prefersOAuthFileOverEnv() throws { - let key = "CLAWDIS_OAUTH_DIR" - let previous = ProcessInfo.processInfo.environment[key] - defer { - if let previous { - setenv(key, previous, 1) - } else { - unsetenv(key) - } - } - let dir = FileManager.default.temporaryDirectory .appendingPathComponent("clawdis-oauth-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - setenv(key, dir.path, 1) - let oauthFile = dir.appendingPathComponent("oauth.json") let payload = [ "anthropic": [ @@ -33,9 +21,10 @@ struct AnthropicAuthResolverTests { let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]) try data.write(to: oauthFile, options: [.atomic]) + let status = ClawdisOAuthStore.anthropicOAuthStatus(at: oauthFile) let mode = AnthropicAuthResolver.resolve(environment: [ "ANTHROPIC_API_KEY": "sk-ant-ignored", - ]) + ], oauthStatus: status) #expect(mode == .oauthFile) } diff --git a/apps/macos/Tests/ClawdisIPCTests/ConnectionsSettingsSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/ConnectionsSettingsSmokeTests.swift new file mode 100644 index 000000000..4941b0524 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/ConnectionsSettingsSmokeTests.swift @@ -0,0 +1,100 @@ +import SwiftUI +import Testing +@testable import Clawdis + +@Suite(.serialized) +@MainActor +struct ConnectionsSettingsSmokeTests { + @Test func connectionsSettingsBuildsBodyWithSnapshot() { + let store = ConnectionsStore(isPreview: true) + store.snapshot = ProvidersStatusSnapshot( + ts: 1_700_000_000_000, + whatsapp: ProvidersStatusSnapshot.WhatsAppStatus( + configured: true, + linked: true, + authAgeMs: 86_400_000, + self: ProvidersStatusSnapshot.WhatsAppSelf( + e164: "+15551234567", + jid: nil), + running: true, + connected: false, + lastConnectedAt: 1_700_000_000_000, + lastDisconnect: ProvidersStatusSnapshot.WhatsAppDisconnect( + at: 1_700_000_050_000, + status: 401, + error: "logged out", + loggedOut: true), + reconnectAttempts: 2, + lastMessageAt: 1_700_000_060_000, + lastEventAt: 1_700_000_060_000, + lastError: "needs login"), + telegram: ProvidersStatusSnapshot.TelegramStatus( + configured: true, + tokenSource: "env", + running: true, + mode: "polling", + lastStartAt: 1_700_000_000_000, + lastStopAt: nil, + lastError: nil, + probe: ProvidersStatusSnapshot.TelegramProbe( + ok: true, + status: 200, + error: nil, + elapsedMs: 120, + bot: ProvidersStatusSnapshot.TelegramBot(id: 123, username: "clawdisbot"), + webhook: ProvidersStatusSnapshot.TelegramWebhook(url: "https://example.com/hook", hasCustomCert: false)), + lastProbeAt: 1_700_000_050_000)) + + store.whatsappLoginMessage = "Scan QR" + store.whatsappLoginQrDataUrl = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/ay7pS8AAAAASUVORK5CYII=" + store.telegramToken = "123:abc" + store.telegramRequireMention = false + store.telegramAllowFrom = "123456789" + store.telegramProxy = "socks5://localhost:9050" + store.telegramWebhookUrl = "https://example.com/telegram" + store.telegramWebhookSecret = "secret" + store.telegramWebhookPath = "/telegram" + + let view = ConnectionsSettings(store: store) + _ = view.body + } + + @Test func connectionsSettingsBuildsBodyWithoutSnapshot() { + let store = ConnectionsStore(isPreview: true) + store.snapshot = ProvidersStatusSnapshot( + ts: 1_700_000_000_000, + whatsapp: ProvidersStatusSnapshot.WhatsAppStatus( + configured: false, + linked: false, + authAgeMs: nil, + self: nil, + running: false, + connected: false, + lastConnectedAt: nil, + lastDisconnect: nil, + reconnectAttempts: 0, + lastMessageAt: nil, + lastEventAt: nil, + lastError: nil), + telegram: ProvidersStatusSnapshot.TelegramStatus( + configured: false, + tokenSource: nil, + running: false, + mode: nil, + lastStartAt: nil, + lastStopAt: nil, + lastError: "bot missing", + probe: ProvidersStatusSnapshot.TelegramProbe( + ok: false, + status: 403, + error: "unauthorized", + elapsedMs: 120, + bot: nil, + webhook: nil), + lastProbeAt: 1_700_000_100_000)) + + let view = ConnectionsSettings(store: store) + _ = view.body + } +} diff --git a/apps/macos/Tests/ClawdisIPCTests/CronJobEditorSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/CronJobEditorSmokeTests.swift index e0eb77d2a..31cf34388 100644 --- a/apps/macos/Tests/ClawdisIPCTests/CronJobEditorSmokeTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/CronJobEditorSmokeTests.swift @@ -55,5 +55,14 @@ struct CronJobEditorSmokeTests { onSave: { _ in }) _ = view.body } -} + @Test func cronJobEditorExercisesBuilders() { + var view = CronJobEditor( + job: nil, + isSaving: .constant(false), + error: .constant(nil), + onCancel: {}, + onSave: { _ in }) + view.exerciseForTesting() + } +} diff --git a/apps/macos/Tests/ClawdisIPCTests/InstancesSettingsSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/InstancesSettingsSmokeTests.swift new file mode 100644 index 000000000..9e408d64c --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/InstancesSettingsSmokeTests.swift @@ -0,0 +1,59 @@ +import Testing +@testable import Clawdis + +@Suite(.serialized) +@MainActor +struct InstancesSettingsSmokeTests { + @Test func instancesSettingsBuildsBodyWithMultipleInstances() { + let store = InstancesStore(isPreview: true) + store.statusMessage = "Loaded" + store.instances = [ + InstanceInfo( + id: "macbook", + host: "macbook-pro", + ip: "10.0.0.2", + version: "1.2.3", + platform: "macOS 15.1", + deviceFamily: "Mac", + modelIdentifier: "MacBookPro18,1", + lastInputSeconds: 15, + mode: "local", + reason: "heartbeat", + text: "MacBook Pro local", + ts: 1_700_000_000_000), + InstanceInfo( + id: "android", + host: "pixel", + ip: "10.0.0.3", + version: "2.0.0", + platform: "Android 14", + deviceFamily: "Android", + modelIdentifier: nil, + lastInputSeconds: 120, + mode: "node", + reason: "presence", + text: "Android node", + ts: 1_700_000_100_000), + InstanceInfo( + id: "gateway", + host: "gateway", + ip: "10.0.0.4", + version: "3.0.0", + platform: "iOS 17", + deviceFamily: nil, + modelIdentifier: nil, + lastInputSeconds: nil, + mode: "gateway", + reason: "gateway", + text: "Gateway", + ts: 1_700_000_200_000), + ] + + let view = InstancesSettings(store: store) + _ = view.body + } + + @Test func instancesSettingsExercisesHelpers() { + InstancesSettings.exerciseForTesting() + } +} diff --git a/apps/macos/Tests/ClawdisIPCTests/SettingsViewSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/SettingsViewSmokeTests.swift index acaa6c666..7f9b4c113 100644 --- a/apps/macos/Tests/ClawdisIPCTests/SettingsViewSmokeTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/SettingsViewSmokeTests.swift @@ -13,6 +13,7 @@ struct SettingsViewSmokeTests { let job1 = CronJob( id: "job-1", name: " Morning Check-in ", + description: nil, enabled: true, createdAtMs: 1_700_000_000_000, updatedAtMs: 1_700_000_100_000, @@ -31,7 +32,8 @@ struct SettingsViewSmokeTests { let job2 = CronJob( id: "job-2", - name: nil, + name: "", + description: nil, enabled: false, createdAtMs: 1_700_000_000_000, updatedAtMs: 1_700_000_100_000, @@ -74,6 +76,10 @@ struct SettingsViewSmokeTests { _ = view.body } + @Test func cronSettingsExercisesPrivateViews() { + CronSettings.exerciseForTesting() + } + @Test func configSettingsBuildsBody() { let view = ConfigSettings() _ = view.body @@ -90,6 +96,10 @@ struct SettingsViewSmokeTests { _ = view.body } + @Test func generalSettingsExercisesBranches() { + GeneralSettings.exerciseForTesting() + } + @Test func sessionsSettingsBuildsBody() { let view = SessionsSettings(rows: SessionRow.previewRows, isPreview: true) _ = view.body diff --git a/apps/macos/Tests/ClawdisIPCTests/SkillsSettingsSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/SkillsSettingsSmokeTests.swift new file mode 100644 index 000000000..271998d86 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/SkillsSettingsSmokeTests.swift @@ -0,0 +1,118 @@ +import Testing +@testable import Clawdis + +@Suite(.serialized) +@MainActor +struct SkillsSettingsSmokeTests { + @Test func skillsSettingsBuildsBodyWithSkillsRemote() { + let model = SkillsSettingsModel() + model.statusMessage = "Loaded" + model.skills = [ + SkillStatus( + name: "Needs Setup", + description: "Missing bins and env", + source: "clawdis-managed", + filePath: "/tmp/skills/needs-setup", + baseDir: "/tmp/skills", + skillKey: "needs-setup", + primaryEnv: "API_KEY", + emoji: "๐Ÿงฐ", + homepage: "https://example.com/needs-setup", + always: false, + disabled: false, + eligible: false, + requirements: SkillRequirements( + bins: ["python3"], + env: ["API_KEY"], + config: ["skills.needs-setup"]), + missing: SkillMissing( + bins: ["python3"], + env: ["API_KEY"], + config: ["skills.needs-setup"]), + configChecks: [ + SkillStatusConfigCheck(path: "skills.needs-setup", value: AnyCodable(false), satisfied: false), + ], + install: [ + SkillInstallOption(id: "brew", kind: "brew", label: "brew install python", bins: ["python3"]), + ]), + SkillStatus( + name: "Ready Skill", + description: "All set", + source: "clawdis-bundled", + filePath: "/tmp/skills/ready", + baseDir: "/tmp/skills", + skillKey: "ready", + primaryEnv: nil, + emoji: "โœ…", + homepage: "https://example.com/ready", + always: false, + disabled: false, + eligible: true, + requirements: SkillRequirements(bins: [], env: [], config: []), + missing: SkillMissing(bins: [], env: [], config: []), + configChecks: [ + SkillStatusConfigCheck(path: "skills.ready", value: AnyCodable(true), satisfied: true), + SkillStatusConfigCheck(path: "skills.limit", value: AnyCodable(5), satisfied: true), + ], + install: []), + SkillStatus( + name: "Disabled Skill", + description: "Disabled in config", + source: "clawdis-extra", + filePath: "/tmp/skills/disabled", + baseDir: "/tmp/skills", + skillKey: "disabled", + primaryEnv: nil, + emoji: "๐Ÿšซ", + homepage: nil, + always: false, + disabled: true, + eligible: false, + requirements: SkillRequirements(bins: [], env: [], config: []), + missing: SkillMissing(bins: [], env: [], config: []), + configChecks: [], + install: []), + ] + + let state = AppState(preview: true) + state.connectionMode = .remote + var view = SkillsSettings(state: state, model: model) + view.setFilterForTesting("all") + _ = view.body + view.setFilterForTesting("needsSetup") + _ = view.body + } + + @Test func skillsSettingsBuildsBodyWithLocalMode() { + let model = SkillsSettingsModel() + model.skills = [ + SkillStatus( + name: "Local Skill", + description: "Local ready", + source: "clawdis-workspace", + filePath: "/tmp/skills/local", + baseDir: "/tmp/skills", + skillKey: "local", + primaryEnv: nil, + emoji: "๐Ÿ ", + homepage: nil, + always: false, + disabled: false, + eligible: true, + requirements: SkillRequirements(bins: [], env: [], config: []), + missing: SkillMissing(bins: [], env: [], config: []), + configChecks: [], + install: []), + ] + + let state = AppState(preview: true) + state.connectionMode = .local + var view = SkillsSettings(state: state, model: model) + view.setFilterForTesting("ready") + _ = view.body + } + + @Test func skillsSettingsExercisesPrivateViews() { + SkillsSettings.exerciseForTesting() + } +} diff --git a/apps/macos/Tests/ClawdisIPCTests/TailscaleIntegrationSectionTests.swift b/apps/macos/Tests/ClawdisIPCTests/TailscaleIntegrationSectionTests.swift new file mode 100644 index 000000000..6e32a629c --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/TailscaleIntegrationSectionTests.swift @@ -0,0 +1,48 @@ +import SwiftUI +import Testing +@testable import Clawdis + +@Suite(.serialized) +@MainActor +struct TailscaleIntegrationSectionTests { + @Test func tailscaleSectionBuildsBodyWhenNotInstalled() { + let service = TailscaleService(isInstalled: false, isRunning: false, statusError: "not installed") + var view = TailscaleIntegrationSection(connectionMode: .local, isPaused: false) + view.setTestingService(service) + view.setTestingState(mode: "off", requireCredentials: false, statusMessage: "Idle") + _ = view.body + } + + @Test func tailscaleSectionBuildsBodyForServeMode() { + let service = TailscaleService( + isInstalled: true, + isRunning: true, + tailscaleHostname: "clawdis.tailnet.ts.net", + tailscaleIP: "100.64.0.1") + var view = TailscaleIntegrationSection(connectionMode: .local, isPaused: false) + view.setTestingService(service) + view.setTestingState( + mode: "serve", + requireCredentials: true, + password: "secret", + statusMessage: "Running") + _ = view.body + } + + @Test func tailscaleSectionBuildsBodyForFunnelMode() { + let service = TailscaleService( + isInstalled: true, + isRunning: false, + tailscaleHostname: nil, + tailscaleIP: nil, + statusError: "not running") + var view = TailscaleIntegrationSection(connectionMode: .remote, isPaused: false) + view.setTestingService(service) + view.setTestingState( + mode: "funnel", + requireCredentials: false, + statusMessage: "Needs start", + validationMessage: "Invalid token") + _ = view.body + } +}