test: expand settings coverage

This commit is contained in:
Peter Steinberger
2025-12-24 17:42:14 +01:00
parent 7fafe54e16
commit deec315f6a
15 changed files with 766 additions and 32 deletions

View File

@@ -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<Date, Never> = {
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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