diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 49519ae..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - test: - name: ConsaiCore tests - # Apple's `container` SDK requires macOS 26 / Xcode 26. CI runs only the pure - # ConsaiCore unit tests — they need no running `container` service. - runs-on: macos-26 - steps: - - uses: actions/checkout@v4 - - name: Show toolchain - run: swift --version - - name: Build - run: swift build - - name: Test - run: swift test diff --git a/App/ConsaiApp.swift b/App/ConsaiApp.swift index f976dee..85269b8 100644 --- a/App/ConsaiApp.swift +++ b/App/ConsaiApp.swift @@ -1,4 +1,5 @@ import SwiftUI +import ConsaiKit import AppKit /// Entry point. `--render-shots ` renders UI screenshots and exits; otherwise the diff --git a/App/Shots/MockEngines.swift b/App/Shots/MockEngines.swift deleted file mode 100644 index 216dbe7..0000000 --- a/App/Shots/MockEngines.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation -import ConsaiCore - -// Lightweight mocks used only by the `--render-shots` screenshot harness, so views can be -// rendered with representative data without a live daemon. - -struct MockContainerEngine: ContainerEngine { - let containers: [Container] - func list() async throws -> [Container] { containers } - func start(id: String) async throws {} - func stop(id: String) async throws {} - func restart(id: String) async throws {} - func delete(id: String) async throws {} - func memoryUsage(id: String) async -> UInt64? { containers.first { $0.id == id }?.memoryBytes } - func cpuUsage(id: String) async -> UInt64? { nil } // cpu% needs sampling; mocks preset cpuPercent - func detail(id: String) async throws -> ContainerDetail { - ContainerDetail(id: id, image: containers.first { $0.id == id }?.image ?? "img", - command: "sleep 3600", env: ["PATH=/usr/bin", "TZ=UTC"], - ports: [PortBinding(host: 8080, container: 80, proto: "tcp")], - mounts: [MountBinding(source: "/data", destination: "/var/data")], - startedAt: nil) - } -} - -struct MockComposeEngine: ComposeEngine { - let isAvailable: Bool - func up(file: URL) async throws {} - func down(file: URL) async throws {} -} - -struct MockServiceHealth: ServiceHealthChecking { - let value: ServiceStatus - func status() async -> ServiceStatus { value } - func start() async throws {} - func stop() async throws {} -} - -struct MockCreator: ContainerCreating { - func create(_ spec: NewContainerSpec) async throws {} -} diff --git a/App/Shots/ShotRenderer.swift b/App/Shots/ShotRenderer.swift index d6280f1..6a046f1 100644 --- a/App/Shots/ShotRenderer.swift +++ b/App/Shots/ShotRenderer.swift @@ -1,6 +1,7 @@ import SwiftUI import AppKit import ConsaiCore +import ConsaiKit /// Screenshot harness for the `--render-shots ` entry mode. Hosts the REAL SwiftUI /// views in real NSWindows backed by the REAL AppState (live daemon data), then captures diff --git a/App/Views/Banners.swift b/App/Views/Banners.swift index 3b5ebf7..eb0cd2b 100644 --- a/App/Views/Banners.swift +++ b/App/Views/Banners.swift @@ -1,4 +1,5 @@ import SwiftUI +import ConsaiKit /// Shown when the `container` system service is not running. struct ServiceBanner: View { diff --git a/App/Views/ContainerRow.swift b/App/Views/ContainerRow.swift index 006f625..293aeb2 100644 --- a/App/Views/ContainerRow.swift +++ b/App/Views/ContainerRow.swift @@ -1,5 +1,6 @@ import SwiftUI import ConsaiCore +import ConsaiKit /// One container row (used standalone and inside a stack branch): living dot, name + image, /// and IP/state vitals on the right; hover reveals quick actions. diff --git a/App/Views/CreateContainerWindow.swift b/App/Views/CreateContainerWindow.swift index 0c3b615..10d2909 100644 --- a/App/Views/CreateContainerWindow.swift +++ b/App/Views/CreateContainerWindow.swift @@ -1,5 +1,6 @@ import SwiftUI import ConsaiCore +import ConsaiKit import AppKit /// Form to create + run a new container (`container run -d …`). diff --git a/App/Views/DetailWindow.swift b/App/Views/DetailWindow.swift index 6241357..18091d4 100644 --- a/App/Views/DetailWindow.swift +++ b/App/Views/DetailWindow.swift @@ -1,25 +1,8 @@ import SwiftUI import ConsaiCore +import ConsaiKit import AppKit -/// Opens an interactive shell into a container via Terminal. -enum ContainerShell { - static func openShell(binaryPath: String, id: String) { - // Refuse ids that aren't plain container names — they're interpolated into an - // AppleScript/shell `do script`, so anything exotic is a command-injection risk. - guard isValidContainerName(id) else { return } - let command = containerExecCommand(binary: binaryPath, id: id) - // Escape for AppleScript string literal (defense-in-depth). - let escaped = command.replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - let script = "tell application \"Terminal\"\nactivate\ndo script \"\(escaped)\"\nend tell" - let proc = Process() - proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") - proc.arguments = ["-e", script] - try? proc.run() - } -} - /// Per-container detail: image, command, started, env, ports, mounts; open a shell or logs. struct DetailWindow: View { @Environment(AppState.self) private var appState diff --git a/App/Views/ImagesWindow.swift b/App/Views/ImagesWindow.swift index 71826ef..a8ad64a 100644 --- a/App/Views/ImagesWindow.swift +++ b/App/Views/ImagesWindow.swift @@ -1,5 +1,6 @@ import SwiftUI import ConsaiCore +import ConsaiKit import AppKit /// Browse local images, pull by reference, delete. diff --git a/App/Views/InfraWindow.swift b/App/Views/InfraWindow.swift index d440162..dd287e9 100644 --- a/App/Views/InfraWindow.swift +++ b/App/Views/InfraWindow.swift @@ -1,5 +1,6 @@ import SwiftUI import ConsaiCore +import ConsaiKit import AppKit /// Networks & volumes: list, create by name, delete. diff --git a/App/Views/PanelView.swift b/App/Views/PanelView.swift index 79958ae..b7ae1c4 100644 --- a/App/Views/PanelView.swift +++ b/App/Views/PanelView.swift @@ -1,5 +1,6 @@ import SwiftUI import ConsaiCore +import ConsaiKit /// The menu bar panel — Bonsai look: mark + wordmark, leaf-marked stacks on branches, /// standalone containers, a tend-the-garden footer. diff --git a/App/Views/SettingsWindow.swift b/App/Views/SettingsWindow.swift index dbda8de..c4d22ca 100644 --- a/App/Views/SettingsWindow.swift +++ b/App/Views/SettingsWindow.swift @@ -1,5 +1,6 @@ import SwiftUI import ConsaiCore +import ConsaiKit import AppKit /// Settings: container system service control, compose availability, poll cadence. diff --git a/App/Views/StackSection.swift b/App/Views/StackSection.swift index a221730..dbb2524 100644 --- a/App/Views/StackSection.swift +++ b/App/Views/StackSection.swift @@ -1,5 +1,6 @@ import SwiftUI import ConsaiCore +import ConsaiKit import AppKit /// A compose stack as a "plant": a leaf-marked header whose services hang off a branch. diff --git a/CLAUDE.md b/CLAUDE.md index 938c593..c79221f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,8 +110,9 @@ window-close and `applicationWillTerminate`. ### R7 — Build environment not present here (MEDIUM) On this machine `xcodebuild`/Xcode 26 was not detected and `container-compose` is not installed, so the app **cannot be compile-verified locally yet**. `container` *is* present. -**Mitigation:** treat "builds in Xcode 26" as a manual gate the developer runs; CI runs only -the pure `ConsaiCore` tests. +**Mitigation:** treat "builds in Xcode 26" as a manual gate the developer runs. There is no +hosted CI (Apple's SDK graph can't build on hosted runners) — verify locally with `swift +test` / `swift package check` (the `check` SwiftPM command plugin). See `docs/TESTING.md`. ### R8 — Crowded ecosystem (LOW / product) Many compose tools and full GUIs already exist; the menu-bar-first niche is the diff --git a/ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift b/ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift index fc677c9..8afb270 100644 --- a/ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift +++ b/ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift @@ -59,6 +59,25 @@ final class SpyProcessRunner: ProcessRunning, @unchecked Sendable { try await engine.up(file: composeFile) } } + + @Test func availableWhenBinaryPresent() { + #expect(CLIComposeEngine(binaryURL: binary, runner: SpyProcessRunner()).isAvailable) + } + + @Test func downFailureFallsBackToStdoutWhenStderrEmpty() async { + let spy = SpyProcessRunner(result: ProcessResult(exitCode: 1, stdout: "no such stack", stderr: "")) + let engine = CLIComposeEngine(binaryURL: binary, runner: spy) + await #expect(throws: ConsaiError.self) { try await engine.down(file: composeFile) } + } + + @Test func resolveBinaryPrefersExecutableExplicitPath() throws { + let exe = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("container-compose-\(UUID().uuidString)") + try "#!/bin/sh\n".write(to: exe, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: exe.path) + defer { try? FileManager.default.removeItem(at: exe) } + #expect(CLIComposeEngine.resolveBinary(explicit: exe.path) == exe) + } } @Suite struct CLIContainerCreatorTests { @@ -172,4 +191,113 @@ final class SpyProcessRunner: ProcessRunning, @unchecked Sendable { try await health.start() #expect(spy.invocations.first?.arguments == ["system", "start"]) } + + @Test func stopSendsSystemStop() async throws { + let spy = SpyProcessRunner() + let health = CLIServiceHealth(binaryURL: URL(fileURLWithPath: "/usr/local/bin/container"), runner: spy) + try await health.stop() + #expect(spy.invocations.first?.arguments == ["system", "stop"]) + } + + @Test func statusRunsCliAndParsesOutput() async { + let spy = SpyProcessRunner(result: ProcessResult(exitCode: 0, stdout: "apiserver is running", stderr: "")) + let health = CLIServiceHealth(binaryURL: URL(fileURLWithPath: "/usr/local/bin/container"), runner: spy) + let status = await health.status() + #expect(status == .running) + #expect(spy.invocations.first?.arguments == ["system", "status"]) + } + + @Test func statusUnknownWhenBinaryMissing() async { + let health = CLIServiceHealth(binaryURL: nil, runner: SpyProcessRunner()) + #expect(await health.status() == .unknown) + } + + @Test func startThrowsOnNonZeroExitAndMissingBinary() async { + let failing = SpyProcessRunner(result: ProcessResult(exitCode: 1, stdout: "", stderr: "permission denied")) + let health = CLIServiceHealth(binaryURL: URL(fileURLWithPath: "/usr/local/bin/container"), runner: failing) + await #expect(throws: ConsaiError.self) { try await health.start() } + + let noBinary = CLIServiceHealth(binaryURL: nil, runner: SpyProcessRunner()) + await #expect(throws: ConsaiError.self) { try await noBinary.start() } + } + + @Test func resolveBinaryPrefersExecutableExplicitPath() throws { + // An explicit, executable path is preferred over the built-in default candidates. + let exe = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("container-\(UUID().uuidString)") + try "#!/bin/sh\n".write(to: exe, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: exe.path) + defer { try? FileManager.default.removeItem(at: exe) } + #expect(CLIServiceHealth.resolveBinary(explicit: exe.path) == exe) + } +} + +@Suite struct CLIContainerCreatorRunTests { + @Test func createSpawnsRunWithBuiltArguments() async throws { + let spy = SpyProcessRunner() + let creator = CLIContainerCreator(binaryURL: URL(fileURLWithPath: "/usr/local/bin/container"), runner: spy) + try await creator.create(NewContainerSpec(image: "redis", name: "cache")) + #expect(spy.invocations.first?.executable == "/usr/local/bin/container") + #expect(spy.invocations.first?.arguments == ["run", "-d", "--name", "cache", "redis"]) + } + + @Test func createThrowsOnNonZeroExit() async { + let spy = SpyProcessRunner(result: ProcessResult(exitCode: 125, stdout: "", stderr: "no such image")) + let creator = CLIContainerCreator(binaryURL: URL(fileURLWithPath: "/usr/local/bin/container"), runner: spy) + await #expect(throws: ConsaiError.self) { try await creator.create(NewContainerSpec(image: "ghost")) } + } + + @Test func createThrowsWhenBinaryMissing() async { + let creator = CLIContainerCreator(binaryURL: nil, runner: SpyProcessRunner()) + await #expect(throws: ConsaiError.self) { try await creator.create(NewContainerSpec(image: "redis")) } + } +} + +@Suite struct RegistryStorePersistenceTests { + private func tempDir() -> URL { + URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("consai-store-\(UUID().uuidString)", isDirectory: true) + } + + @Test func saveThenLoadRoundTrips() throws { + let dir = tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let store = RegistryStore(directory: dir) + + var registry = ProjectRegistry() + registry.record(project: "shop", composeFile: URL(fileURLWithPath: "/p/shop/docker-compose.yml")) + try store.save(registry) + + let loaded = store.load() + #expect(loaded.knownProjects["shop"]?.path == "/p/shop/docker-compose.yml") + #expect(loaded.recentComposeFiles.count == 1) + #expect(loaded == registry) + } + + @Test func loadFromEmptyDirectoryReturnsEmptyRegistry() { + let store = RegistryStore(directory: tempDir()) + #expect(store.load() == ProjectRegistry()) + } + + @Test func loadIgnoresCorruptFile() throws { + let dir = tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try "not json".write(to: dir.appendingPathComponent("registry.json"), atomically: true, encoding: .utf8) + // Corrupt JSON decodes to an empty registry rather than throwing. + #expect(RegistryStore(directory: dir).load() == ProjectRegistry()) + } + + @Test func defaultDirectoryIsUnderApplicationSupport() { + #expect(RegistryStore.defaultDirectory().path.hasSuffix("/Consai")) + } +} + +@Suite struct ContainerImageDigestTests { + @Test func shortDigestHandlesDigestWithoutAlgorithmPrefix() { + // No ":" → take the raw hex prefix, not an empty split tail. + #expect(ContainerImage(reference: "x", digest: "abcdef0123456789").shortDigest == "abcdef012345") + #expect(ContainerImage(reference: "x", digest: "short").shortDigest == "short") + #expect(ContainerImage(reference: "nginx:latest", digest: "d").id == "nginx:latest") + } } diff --git a/App/AppState.swift b/ConsaiKit/Sources/ConsaiKit/AppState.swift similarity index 78% rename from App/AppState.swift rename to ConsaiKit/Sources/ConsaiKit/AppState.swift index 83d2b83..d9b5664 100644 --- a/App/AppState.swift +++ b/ConsaiKit/Sources/ConsaiKit/AppState.swift @@ -4,20 +4,23 @@ import ConsaiCore /// The single source of truth for the UI. Owns the engines, polls for container/service /// state, folds containers into stacks, and applies optimistic updates on actions. +/// +/// Lives in `ConsaiKit` (not the app executable) so its orchestration logic is unit-testable +/// with injected mock engines. @MainActor @Observable -final class AppState { - private(set) var containers: [Container] = [] - private(set) var stacks: [Stack] = [] - private(set) var standalone: [Container] = [] - private(set) var serviceStatus: ServiceStatus = .unknown +public final class AppState { + public private(set) var containers: [Container] = [] + public private(set) var stacks: [Stack] = [] + public private(set) var standalone: [Container] = [] + public private(set) var serviceStatus: ServiceStatus = .unknown /// Container ids / project names with an action in flight (drives spinners / disabling). - private(set) var inFlight: Set = [] - var lastError: String? + public private(set) var inFlight: Set = [] + public var lastError: String? - private(set) var images: [ContainerImage] = [] - private(set) var networks: [ContainerNetwork] = [] - private(set) var volumes: [ContainerVolume] = [] + public private(set) var images: [ContainerImage] = [] + public private(set) var networks: [ContainerNetwork] = [] + public private(set) var volumes: [ContainerVolume] = [] private let containerEngine: ContainerEngine private let composeEngine: ComposeEngine @@ -31,12 +34,12 @@ final class AppState { private var panelVisible = false private var sampler = VitalsSampler() - var runningCount: Int { containers.filter { $0.status == .running }.count } - var isServiceRunning: Bool { serviceStatus == .running } - var composeAvailable: Bool { composeEngine.isAvailable } - var recentComposeFiles: [URL] { registry.recentComposeFiles } + public var runningCount: Int { containers.filter { $0.status == .running }.count } + public var isServiceRunning: Bool { serviceStatus == .running } + public var composeAvailable: Bool { composeEngine.isAvailable } + public var recentComposeFiles: [URL] { registry.recentComposeFiles } - var menuBarSymbol: String { + public var menuBarSymbol: String { switch serviceStatus { case .running: return "leaf.fill" case .stopped: return "exclamationmark.triangle.fill" @@ -45,19 +48,21 @@ final class AppState { } /// A user-configured binary path from settings, or nil to auto-detect (empty = auto). - static func storedPath(_ key: String) -> String? { + public static func storedPath(_ key: String) -> String? { let value = UserDefaults.standard.string(forKey: key) return (value?.isEmpty == false) ? value : nil } - init( + /// - Parameter autostart: begin polling on init (false in tests for deterministic control). + public init( containerEngine: ContainerEngine = SDKContainerEngine(), composeEngine: ComposeEngine = CLIComposeEngine(binaryPath: AppState.storedPath("composeBinaryPath")), serviceHealth: ServiceHealthChecking = CLIServiceHealth(binaryPath: AppState.storedPath("containerBinaryPath")), creator: ContainerCreating = CLIContainerCreator(binaryPath: AppState.storedPath("containerBinaryPath")), imageEngine: ImageEngine = SDKImageEngine(binaryPath: AppState.storedPath("containerBinaryPath")), infraEngine: InfraEngine = SDKInfraEngine(binaryPath: AppState.storedPath("containerBinaryPath")), - store: RegistryStore = RegistryStore() + store: RegistryStore = RegistryStore(), + autostart: Bool = true ) { self.containerEngine = containerEngine self.composeEngine = composeEngine @@ -67,12 +72,12 @@ final class AppState { self.infraEngine = infraEngine self.store = store self.registry = store.load() - startPolling() + if autostart { startPolling() } } // MARK: - Polling - func startPolling() { + public func startPolling() { guard pollTask == nil else { return } pollTask = Task { [weak self] in while !Task.isCancelled { @@ -86,17 +91,17 @@ final class AppState { } } - func stopPolling() { + public func stopPolling() { pollTask?.cancel() pollTask = nil } - func setPanelVisible(_ visible: Bool) { + public func setPanelVisible(_ visible: Bool) { panelVisible = visible if visible { Task { await refresh() } } } - func refresh() async { + public func refresh() async { serviceStatus = await serviceHealth.status() // Only a definite "stopped" clears the list. On `.unknown` we still try to list // (the status wording just wasn't recognized); a real failure surfaces as an error. @@ -107,10 +112,8 @@ final class AppState { } do { let fresh = try await containerEngine.list() - // Preserve an in-flight container's optimistic status so a poll mid-action - // doesn't flicker it back to the pre-action state. Carry over last-known memory - // so vitals don't blink to empty between fetches. - // Carry last-known vitals so they don't blink to empty between fetches. + // Preserve an in-flight container's optimistic status so a poll mid-action doesn't + // flicker it back, and carry last-known vitals so they don't blink between fetches. let priorMemory = Dictionary(containers.map { ($0.id, $0.memoryBytes) }, uniquingKeysWith: { a, _ in a }) let priorCPU = Dictionary(containers.map { ($0.id, $0.cpuPercent) }, uniquingKeysWith: { a, _ in a }) containers = fresh.map { incoming in @@ -170,11 +173,11 @@ final class AppState { // MARK: - Container actions - func start(_ id: String) async { await act(id, optimistic: .starting) { try await self.containerEngine.start(id: id) } } - func stop(_ id: String) async { await act(id, optimistic: .stopping) { try await self.containerEngine.stop(id: id) } } - func restart(_ id: String) async { await act(id, optimistic: .starting) { try await self.containerEngine.restart(id: id) } } + public func start(_ id: String) async { await act(id, optimistic: .starting) { try await self.containerEngine.start(id: id) } } + public func stop(_ id: String) async { await act(id, optimistic: .stopping) { try await self.containerEngine.stop(id: id) } } + public func restart(_ id: String) async { await act(id, optimistic: .starting) { try await self.containerEngine.restart(id: id) } } - func delete(_ id: String) async { + public func delete(_ id: String) async { inFlight.insert(id) defer { inFlight.remove(id) } do { @@ -188,7 +191,7 @@ final class AppState { // MARK: - Stack actions /// Bring a compose stack up. Records the project so future polls group it as known. - func composeUp(file: URL) async { + public func composeUp(file: URL) async { let project = Self.projectName(for: file) inFlight.insert(project) defer { inFlight.remove(project) } @@ -203,7 +206,7 @@ final class AppState { } /// Bring a known stack down. Gated on a linked compose file — never guesses. - func composeDown(_ stack: Stack) async { + public func composeDown(_ stack: Stack) async { guard let path = stack.composeFilePath else { lastError = "No compose file linked for \(stack.projectName). Link one first." return @@ -219,19 +222,19 @@ final class AppState { } /// Promote an inferred stack to known by pointing it at its compose file. - func linkComposeFile(project: String, file: URL) { + public func linkComposeFile(project: String, file: URL) { registry.record(project: project, composeFile: file) persist() reassemble() } - func forgetStack(_ project: String) { + public func forgetStack(_ project: String) { registry.remove(project: project) persist() reassemble() } - func startService() async { + public func startService() async { do { try await serviceHealth.start() await refresh() @@ -240,7 +243,7 @@ final class AppState { } } - func stopService() async { + public func stopService() async { do { try await serviceHealth.stop() await refresh() @@ -250,7 +253,7 @@ final class AppState { } /// Create + run a new container. Returns true on success (so the window can close). - func create(_ spec: NewContainerSpec) async -> Bool { + public func create(_ spec: NewContainerSpec) async -> Bool { do { try await creator.create(spec) await refresh() @@ -261,17 +264,17 @@ final class AppState { } } - func clearError() { lastError = nil } + public func clearError() { lastError = nil } // MARK: - Images - func loadImages() async { + public func loadImages() async { do { images = try await imageEngine.list() } catch { lastError = describe(error) } } /// Pull an image; returns true on success. Refreshes the image list. - func pullImage(_ reference: String) async -> Bool { + public func pullImage(_ reference: String) async -> Bool { do { try await imageEngine.pull(reference: reference) await loadImages() @@ -282,7 +285,7 @@ final class AppState { } } - func deleteImage(_ reference: String) async { + public func deleteImage(_ reference: String) async { do { try await imageEngine.delete(reference: reference) await loadImages() @@ -293,20 +296,20 @@ final class AppState { // MARK: - Container detail / exec - func detail(_ id: String) async -> ContainerDetail? { + public func detail(_ id: String) async -> ContainerDetail? { do { return try await containerEngine.detail(id: id) } catch { lastError = describe(error); return nil } } /// Open an interactive shell into the container in Terminal. - func execShell(_ id: String) { + public func execShell(_ id: String) { let binary = AppState.storedPath("containerBinaryPath") ?? "/usr/local/bin/container" ContainerShell.openShell(binaryPath: binary, id: id) } // MARK: - Networks & volumes - func loadInfra() async { + public func loadInfra() async { do { async let n = infraEngine.listNetworks() async let v = infraEngine.listVolumes() @@ -317,10 +320,10 @@ final class AppState { } } - func createNetwork(_ name: String) async { await infraOp { try await self.infraEngine.createNetwork(name: name) } } - func deleteNetwork(_ id: String) async { await infraOp { try await self.infraEngine.deleteNetwork(id: id) } } - func createVolume(_ name: String) async { await infraOp { try await self.infraEngine.createVolume(name: name) } } - func deleteVolume(_ name: String) async { await infraOp { try await self.infraEngine.deleteVolume(name: name) } } + public func createNetwork(_ name: String) async { await infraOp { try await self.infraEngine.createNetwork(name: name) } } + public func deleteNetwork(_ id: String) async { await infraOp { try await self.infraEngine.deleteNetwork(id: id) } } + public func createVolume(_ name: String) async { await infraOp { try await self.infraEngine.createVolume(name: name) } } + public func deleteVolume(_ name: String) async { await infraOp { try await self.infraEngine.deleteVolume(name: name) } } private func infraOp(_ op: @escaping () async throws -> Void) async { do { try await op(); await loadInfra() } @@ -354,27 +357,25 @@ final class AppState { } } - /// Project name, matching `container-compose`: the compose file's top-level `name:` - /// field if present, else the directory name — with `.`→`_` sanitization either way - /// (so `~/my.app/compose.yml` groups as `my_app-`). - static func projectName(for composeFile: URL) -> String { + /// Project name, matching `container-compose`: the compose file's top-level `name:` field + /// if present, else the directory name — with `.`→`_` sanitization either way. + public static func projectName(for composeFile: URL) -> String { if let explicit = composeProjectName(in: composeFile) { return sanitizeProjectName(explicit) } return sanitizeProjectName(composeFile.deletingLastPathComponent().lastPathComponent) } - static func sanitizeProjectName(_ name: String) -> String { + public static func sanitizeProjectName(_ name: String) -> String { name.replacingOccurrences(of: ".", with: "_") } /// Best-effort scan for a top-level `name:` key in a compose file. - static func composeProjectName(in file: URL) -> String? { + public static func composeProjectName(in file: URL) -> String? { guard let text = try? String(contentsOf: file, encoding: .utf8) else { return nil } for line in text.split(separator: "\n", omittingEmptySubsequences: true) { guard let first = line.first, first != " ", first != "\t" else { continue } // top-level only guard let range = line.range(of: #"^name:\s*"#, options: .regularExpression) else { continue } var value = String(line[range.upperBound...]).trimmingCharacters(in: .whitespaces) if let quote = value.first, quote == "\"" || quote == "'" { - // Quoted scalar: take inner content verbatim ('#' inside is not a comment). let inner = value.dropFirst() if let close = inner.firstIndex(of: quote) { value = String(inner[.. sh`). +public enum ContainerShell { + public static func openShell(binaryPath: String, id: String) { + // Refuse ids that aren't plain container names — they're interpolated into an + // AppleScript/shell `do script`, so anything exotic is a command-injection risk. + guard isValidContainerName(id) else { return } + let command = containerExecCommand(binary: binaryPath, id: id) + // Escape for AppleScript string literal (defense-in-depth). + let escaped = command.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + let script = "tell application \"Terminal\"\nactivate\ndo script \"\(escaped)\"\nend tell" + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + proc.arguments = ["-e", script] + try? proc.run() + } +} diff --git a/ConsaiKit/Sources/ConsaiKit/MockEngines.swift b/ConsaiKit/Sources/ConsaiKit/MockEngines.swift new file mode 100644 index 0000000..568a7bb --- /dev/null +++ b/ConsaiKit/Sources/ConsaiKit/MockEngines.swift @@ -0,0 +1,67 @@ +import Foundation +import ConsaiCore + +// Simple fixed mocks for previews / the `--render-shots` screenshot harness, so views can be +// rendered with representative data without a live daemon. (Richer, controllable fakes for +// unit tests live in the test target.) + +public struct MockContainerEngine: ContainerEngine { + public let containers: [Container] + public init(containers: [Container]) { self.containers = containers } + public func list() async throws -> [Container] { containers } + public func start(id: String) async throws {} + public func stop(id: String) async throws {} + public func restart(id: String) async throws {} + public func delete(id: String) async throws {} + public func memoryUsage(id: String) async -> UInt64? { containers.first { $0.id == id }?.memoryBytes } + public func cpuUsage(id: String) async -> UInt64? { nil } + public func detail(id: String) async throws -> ContainerDetail { + ContainerDetail(id: id, image: containers.first { $0.id == id }?.image ?? "img", + command: "sleep 3600", env: ["PATH=/usr/bin", "TZ=UTC"], + ports: [PortBinding(host: 8080, container: 80, proto: "tcp")], + mounts: [MountBinding(source: "/data", destination: "/var/data")], + startedAt: nil) + } +} + +public struct MockComposeEngine: ComposeEngine { + public let isAvailable: Bool + public init(isAvailable: Bool) { self.isAvailable = isAvailable } + public func up(file: URL) async throws {} + public func down(file: URL) async throws {} +} + +public struct MockServiceHealth: ServiceHealthChecking { + public let value: ServiceStatus + public init(value: ServiceStatus) { self.value = value } + public func status() async -> ServiceStatus { value } + public func start() async throws {} + public func stop() async throws {} +} + +public struct MockCreator: ContainerCreating { + public init() {} + public func create(_ spec: NewContainerSpec) async throws {} +} + +public struct MockImageEngine: ImageEngine { + public let images: [ContainerImage] + public init(images: [ContainerImage] = []) { self.images = images } + public func list() async throws -> [ContainerImage] { images } + public func pull(reference: String) async throws {} + public func delete(reference: String) async throws {} +} + +public struct MockInfraEngine: InfraEngine { + public let networks: [ContainerNetwork] + public let volumes: [ContainerVolume] + public init(networks: [ContainerNetwork] = [], volumes: [ContainerVolume] = []) { + self.networks = networks; self.volumes = volumes + } + public func listNetworks() async throws -> [ContainerNetwork] { networks } + public func createNetwork(name: String) async throws {} + public func deleteNetwork(id: String) async throws {} + public func listVolumes() async throws -> [ContainerVolume] { volumes } + public func createVolume(name: String) async throws {} + public func deleteVolume(name: String) async throws {} +} diff --git a/ConsaiKit/Tests/ConsaiKitTests/AppStateTests.swift b/ConsaiKit/Tests/ConsaiKitTests/AppStateTests.swift new file mode 100644 index 0000000..295acf2 --- /dev/null +++ b/ConsaiKit/Tests/ConsaiKitTests/AppStateTests.swift @@ -0,0 +1,544 @@ +import Testing +import Foundation +@testable import ConsaiKit +@testable import ConsaiCore + +// MARK: - Controllable fakes + +final class FakeContainerEngine: ContainerEngine, @unchecked Sendable { + var listResult: [Container] = [] + var listError: Error? + var memory: [String: UInt64] = [:] + var cpu: [String: UInt64] = [:] + var detailResult: ContainerDetail? + var detailError: Error? + var actionError: Error? // thrown by start/stop/restart/delete + private(set) var started: [String] = [] + private(set) var stopped: [String] = [] + private(set) var restarted: [String] = [] + private(set) var deleted: [String] = [] + + func list() async throws -> [Container] { + if let listError { throw listError } + return listResult + } + func start(id: String) async throws { if let actionError { throw actionError }; started.append(id) } + func stop(id: String) async throws { if let actionError { throw actionError }; stopped.append(id) } + func restart(id: String) async throws { if let actionError { throw actionError }; restarted.append(id) } + func delete(id: String) async throws { if let actionError { throw actionError }; deleted.append(id) } + func memoryUsage(id: String) async -> UInt64? { memory[id] } + func cpuUsage(id: String) async -> UInt64? { cpu[id] } + func detail(id: String) async throws -> ContainerDetail { + if let detailError { throw detailError } + return detailResult ?? ContainerDetail(id: id, image: "img", command: "", env: [], ports: [], mounts: [], startedAt: nil) + } +} + +final class FakeService: ServiceHealthChecking, @unchecked Sendable { + var value: ServiceStatus + var startError: Error? + var stopError: Error? + init(_ v: ServiceStatus) { value = v } + func status() async -> ServiceStatus { value } + func start() async throws { if let startError { throw startError }; value = .running } + func stop() async throws { if let stopError { throw stopError }; value = .stopped } +} + +final class FakeCompose: ComposeEngine, @unchecked Sendable { + var available = true + var upError: Error? + private(set) var upped: [URL] = [] + private(set) var downed: [URL] = [] + var isAvailable: Bool { available } + func up(file: URL) async throws { if let upError { throw upError }; upped.append(file) } + func down(file: URL) async throws { downed.append(file) } +} + +final class FakeCreator: ContainerCreating, @unchecked Sendable { + var error: Error? + private(set) var created: [NewContainerSpec] = [] + func create(_ spec: NewContainerSpec) async throws { if let error { throw error }; created.append(spec) } +} + +final class FakeImages: ImageEngine, @unchecked Sendable { + var listResult: [ContainerImage] = [] + var listError: Error? + var pullError: Error? + var deleteError: Error? + private(set) var pulled: [String] = [] + private(set) var deleted: [String] = [] + func list() async throws -> [ContainerImage] { if let listError { throw listError }; return listResult } + func pull(reference: String) async throws { if let pullError { throw pullError }; pulled.append(reference) } + func delete(reference: String) async throws { if let deleteError { throw deleteError }; deleted.append(reference) } +} + +final class FakeInfra: InfraEngine, @unchecked Sendable { + var nets: [ContainerNetwork] = [] + var vols: [ContainerVolume] = [] + var listError: Error? + var mutateError: Error? // thrown by create/delete network/volume + private(set) var createdNetworks: [String] = [] + private(set) var deletedNetworks: [String] = [] + private(set) var createdVolumes: [String] = [] + private(set) var deletedVolumes: [String] = [] + func listNetworks() async throws -> [ContainerNetwork] { if let listError { throw listError }; return nets } + func createNetwork(name: String) async throws { if let mutateError { throw mutateError }; createdNetworks.append(name) } + func deleteNetwork(id: String) async throws { if let mutateError { throw mutateError }; deletedNetworks.append(id) } + func listVolumes() async throws -> [ContainerVolume] { if let listError { throw listError }; return vols } + func createVolume(name: String) async throws { if let mutateError { throw mutateError }; createdVolumes.append(name) } + func deleteVolume(name: String) async throws { if let mutateError { throw mutateError }; deletedVolumes.append(name) } +} + +// MARK: - Helpers + +@MainActor +private func makeState( + container: FakeContainerEngine = FakeContainerEngine(), + service: FakeService = FakeService(.running), + compose: FakeCompose = FakeCompose(), + creator: FakeCreator = FakeCreator(), + images: FakeImages = FakeImages(), + infra: FakeInfra = FakeInfra() +) -> AppState { + let tmp = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("consai-appstate-\(UUID().uuidString)", isDirectory: true) + UserDefaults.standard.removeObject(forKey: "groupByNamePrefix") + return AppState(containerEngine: container, composeEngine: compose, serviceHealth: service, + creator: creator, imageEngine: images, infraEngine: infra, + store: RegistryStore(directory: tmp), autostart: false) +} + +private func ctr(_ id: String, _ status: ContainerStatus = .running, labels: [String: String] = [:]) -> Container { + Container(id: id, name: id, image: "img", status: status, labels: labels) +} + +// MARK: - Tests + +@MainActor +@Suite struct AppStateRefreshTests { + @Test func runningServiceListsAndAssembles() async { + let engine = FakeContainerEngine() + engine.listResult = [ctr("web"), ctr("db")] + let state = makeState(container: engine) + await state.refresh() + #expect(state.serviceStatus == .running) + #expect(state.containers.count == 2) + #expect(state.standalone.count == 2) // no labels, inference off → standalone + #expect(state.runningCount == 2) + } + + @Test func stoppedServiceClearsList() async { + let engine = FakeContainerEngine(); engine.listResult = [ctr("web")] + let state = makeState(container: engine, service: FakeService(.stopped)) + await state.refresh() + #expect(state.containers.isEmpty) + #expect(!state.isServiceRunning) + } + + @Test func unknownServiceStillLists() async { + let engine = FakeContainerEngine(); engine.listResult = [ctr("web")] + let state = makeState(container: engine, service: FakeService(.unknown)) + await state.refresh() + #expect(state.containers.count == 1) // .unknown still attempts a list + } + + @Test func listErrorSurfacesMessage() async { + let engine = FakeContainerEngine(); engine.listError = ConsaiError.sdk("boom") + let state = makeState(container: engine) + await state.refresh() + #expect(state.lastError == "boom") + } + + @Test func fillsAndCarriesVitals() async { + let engine = FakeContainerEngine() + engine.listResult = [ctr("web")] + engine.memory = ["web": 100 * 1_048_576] + let state = makeState(container: engine) + await state.refresh() + #expect(state.containers.first?.memoryBytes == 100 * 1_048_576) + // Next list omits memory; AppState carries the last-known value. + engine.memory = [:] + await state.refresh() + #expect(state.containers.first?.memoryBytes == 100 * 1_048_576) + } + + @Test func groupsByComposeLabel() async { + let engine = FakeContainerEngine() + let lbl = ProjectRegistry.composeProjectLabel + engine.listResult = [ctr("shop-a", labels: [lbl: "shop"]), ctr("shop-b", labels: [lbl: "shop"]), ctr("lonely")] + let state = makeState(container: engine) + await state.refresh() + #expect(state.stacks.count == 1) + #expect(state.stacks.first?.projectName == "shop") + #expect(state.standalone.map(\.name) == ["lonely"]) + } +} + +@MainActor +@Suite struct AppStateActionTests { + @Test func stopReconcilesToEngineState() async { + let engine = FakeContainerEngine() + engine.listResult = [ctr("web", .running)] + let state = makeState(container: engine) + await state.refresh() + engine.listResult = [ctr("web", .stopped)] // engine reports stopped after the action + await state.stop("web") + #expect(engine.stopped == ["web"]) + #expect(state.containers.first?.status == .stopped) + #expect(state.inFlight.isEmpty) + } + + @Test func deleteCallsEngineAndRefreshes() async { + let engine = FakeContainerEngine() + engine.listResult = [ctr("web")] + let state = makeState(container: engine) + await state.refresh() + engine.listResult = [] + await state.delete("web") + #expect(engine.deleted == ["web"]) + #expect(state.containers.isEmpty) + } + + @Test func createReturnsTrueAndFalse() async { + let creator = FakeCreator() + let state = makeState(creator: creator) + let ok = await state.create(NewContainerSpec(image: "redis")) + #expect(ok) + #expect(creator.created.count == 1) + + creator.error = ConsaiError.processFailed(stderr: "bad image") + let state2 = makeState(creator: creator) + let ok2 = await state2.create(NewContainerSpec(image: "nope")) + #expect(!ok2) + #expect(state2.lastError == "bad image") + } +} + +@MainActor +@Suite struct AppStateComposeTests { + @Test func composeUpRecordsProjectAndDownGates() async { + let engine = FakeContainerEngine() + let compose = FakeCompose() + let state = makeState(container: engine, compose: compose) + + let dir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("proj-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let file = dir.appendingPathComponent("docker-compose.yml") + try? "services: {}".write(to: file, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: dir) } + + await state.composeUp(file: file) + #expect(compose.upped == [file]) + + // down without a linked file → error, no call + await state.composeDown(Stack(projectName: "x", services: [], origin: .inferred)) + #expect(state.lastError?.contains("No compose file") == true) + #expect(compose.downed.isEmpty) + } + + @Test func composeUnavailableReflected() async { + let compose = FakeCompose(); compose.available = false + let state = makeState(compose: compose) + #expect(!state.composeAvailable) + } +} + +@MainActor +@Suite struct AppStateImageInfraTests { + @Test func loadsPullsDeletesImages() async { + let images = FakeImages() + images.listResult = [ContainerImage(reference: "nginx:latest", digest: "sha256:a")] + let state = makeState(images: images) + await state.loadImages() + #expect(state.images.count == 1) + + let ok = await state.pullImage("redis:7") + #expect(ok) + #expect(images.pulled == ["redis:7"]) + + await state.deleteImage("nginx:latest") + #expect(images.deleted == ["nginx:latest"]) + } + + @Test func pullErrorReturnsFalse() async { + let images = FakeImages(); images.pullError = ConsaiError.processFailed(stderr: "no such image") + let state = makeState(images: images) + let ok = await state.pullImage("ghost") + #expect(!ok) + #expect(state.lastError == "no such image") + } + + @Test func loadsAndMutatesInfra() async { + let infra = FakeInfra() + infra.nets = [ContainerNetwork(name: "default", subnet: "10.0.0.0/24")] + infra.vols = [ContainerVolume(name: "pg", driver: "local", source: "/v/pg")] + let state = makeState(infra: infra) + await state.loadInfra() + #expect(state.networks.count == 1) + #expect(state.volumes.count == 1) + + await state.createNetwork("backend") + #expect(infra.createdNetworks == ["backend"]) + await state.deleteVolume("pg") + #expect(infra.deletedVolumes == ["pg"]) + } + + @Test func detailReturnsMappedValue() async { + let engine = FakeContainerEngine() + engine.detailResult = ContainerDetail(id: "web", image: "nginx", command: "nginx", env: ["A=1"], ports: [], mounts: [], startedAt: nil) + let state = makeState(container: engine) + let d = await state.detail("web") + #expect(d?.image == "nginx") + #expect(d?.env == ["A=1"]) + } +} + +@MainActor +@Suite struct AppStateMiscTests { + @Test func menuBarSymbolReflectsService() async { + let running = makeState(service: FakeService(.running)); await running.refresh() + #expect(running.menuBarSymbol == "leaf.fill") + let stopped = makeState(service: FakeService(.stopped)); await stopped.refresh() + #expect(stopped.menuBarSymbol == "exclamationmark.triangle.fill") + let unknown = makeState(service: FakeService(.unknown)); await unknown.refresh() + #expect(unknown.menuBarSymbol == "leaf") + } + + @Test func clearErrorResets() { + let state = makeState() + state.lastError = "x" + state.clearError() + #expect(state.lastError == nil) + } +} + +@MainActor +@Suite struct ProjectNameTests { + @Test func sanitizesDottedDirectoryName() { + let url = URL(fileURLWithPath: "/Users/me/my.app/docker-compose.yml") + #expect(AppState.projectName(for: url) == "my_app") + } + + @Test func readsNameFieldWithCommentAndQuotes() { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("pn-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("docker-compose.yml") + + try? "name: shop # the project\nservices: {}".write(to: file, atomically: true, encoding: .utf8) + #expect(AppState.composeProjectName(in: file) == "shop") + + try? "name: \"my#app\"\nservices: {}".write(to: file, atomically: true, encoding: .utf8) + #expect(AppState.composeProjectName(in: file) == "my#app") // '#' inside quotes is not a comment + } +} + +// MARK: - Error paths + +@MainActor +@Suite struct AppStateErrorTests { + @Test func startStopRestartSurfaceEngineErrors() async { + let engine = FakeContainerEngine() + engine.listResult = [ctr("web")] + let state = makeState(container: engine) + await state.refresh() + engine.actionError = ConsaiError.sdk("daemon refused") + + await state.start("web") + #expect(state.lastError == "daemon refused") + #expect(state.inFlight.isEmpty) // act() always clears in-flight + + state.clearError() + await state.restart("web") + #expect(state.lastError == "daemon refused") + + state.clearError() + await state.stop("web") + #expect(state.lastError == "daemon refused") + + state.clearError() + await state.delete("web") + #expect(state.lastError == "daemon refused") + } + + @Test func startStopServiceSucceedAndSurfaceErrors() async { + let service = FakeService(.stopped) + let state = makeState(service: service) + await state.startService() + #expect(service.value == .running) // start() flipped it + #expect(state.lastError == nil) + + await state.stopService() + #expect(service.value == .stopped) + + service.startError = ConsaiError.sdk("launchd busy") + await state.startService() + #expect(state.lastError == "launchd busy") + + state.clearError() + service.stopError = ConsaiError.sdk("still draining") + await state.stopService() + #expect(state.lastError == "still draining") + } + + @Test func loadImagesAndDeleteImageSurfaceErrors() async { + let images = FakeImages(); images.listError = ConsaiError.processFailed(stderr: "list failed") + let state = makeState(images: images) + await state.loadImages() + #expect(state.lastError == "list failed") + #expect(state.images.isEmpty) + + state.clearError() + images.listError = nil + images.deleteError = ConsaiError.processFailed(stderr: "in use") + await state.deleteImage("nginx:latest") + #expect(state.lastError == "in use") + } + + @Test func loadInfraSurfacesError() async { + let infra = FakeInfra(); infra.listError = ConsaiError.sdk("no daemon") + let state = makeState(infra: infra) + await state.loadInfra() + #expect(state.lastError == "no daemon") + } + + @Test func infraMutationsSucceedAndSurfaceErrors() async { + let infra = FakeInfra() + let state = makeState(infra: infra) + await state.deleteNetwork("backend") + #expect(infra.deletedNetworks == ["backend"]) + await state.createVolume("pgdata") + #expect(infra.createdVolumes == ["pgdata"]) + + infra.mutateError = ConsaiError.processFailed(stderr: "network busy") + await state.createNetwork("frontend") + #expect(state.lastError == "network busy") + } + + @Test func composeUpErrorSurfacesAndDoesNotRecord() async { + let compose = FakeCompose(); compose.upError = ConsaiError.composeMissing + let state = makeState(compose: compose) + let dir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("cu-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("docker-compose.yml") + try? "services: {}".write(to: file, atomically: true, encoding: .utf8) + + await state.composeUp(file: file) + #expect(state.lastError != nil) + #expect(compose.upped.isEmpty) + #expect(state.recentComposeFiles.isEmpty) // failed up does not get recorded + } + + @Test func composeDownRunsWhenFileLinked() async { + let compose = FakeCompose() + let state = makeState(compose: compose) + let dir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("cd-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("docker-compose.yml") + try? "services: {}".write(to: file, atomically: true, encoding: .utf8) + + let stack = Stack(projectName: "shop", composeFilePath: file.path, services: [], origin: .launchedByConsai) + await state.composeDown(stack) + #expect(compose.downed == [file]) + #expect(state.lastError == nil) + } + + @Test func detailErrorReturnsNil() async { + let engine = FakeContainerEngine(); engine.detailError = ConsaiError.sdk("gone") + let state = makeState(container: engine) + let d = await state.detail("web") + #expect(d == nil) + #expect(state.lastError == "gone") + } +} + +// MARK: - Registry & lifecycle + +@MainActor +@Suite struct AppStateLifecycleTests { + // Uses a compose-LABELED stack (always grouped, no global UserDefaults flag) so the test + // is immune to the parallel-test race on `groupByNamePrefix` in UserDefaults.standard. + @Test func linkAndForgetToggleComposeFilePath() async { + let engine = FakeContainerEngine() + let lbl = ProjectRegistry.composeProjectLabel + engine.listResult = [ctr("shop-api", labels: [lbl: "shop"]), ctr("shop-web", labels: [lbl: "shop"])] + let state = makeState(container: engine) + await state.refresh() + #expect(state.stacks.first?.origin == .composeLabeled) + #expect(state.stacks.first?.composeFilePath == nil) + + let dir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("lk-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("docker-compose.yml") + try? "services: {}".write(to: file, atomically: true, encoding: .utf8) + + state.linkComposeFile(project: "shop", file: file) + #expect(state.stacks.first?.composeFilePath == file.path) // registry path now attached + + state.forgetStack("shop") + #expect(state.stacks.first?.composeFilePath == nil) // path dropped on forget + } + + @Test func setPanelVisibleTriggersRefresh() async { + let engine = FakeContainerEngine(); engine.listResult = [ctr("web")] + let state = makeState(container: engine) + state.setPanelVisible(true) // schedules an async refresh + // The refresh is fire-and-forget; yield until it lands. + for _ in 0..<50 where state.containers.isEmpty { await Task.yield() } + #expect(state.containers.count == 1) + state.setPanelVisible(false) // exercises the no-refresh branch + } + + @Test func startPollingIsIdempotentAndStops() async { + let state = makeState() + state.startPolling() + state.startPolling() // second call is a no-op (guard) + state.stopPolling() // tears down cleanly + } + + @Test func storedPathTreatsEmptyAsNil() { + let key = "consai-test-path-\(UUID().uuidString)" + #expect(AppState.storedPath(key) == nil) + UserDefaults.standard.set("", forKey: key) + #expect(AppState.storedPath(key) == nil) + UserDefaults.standard.set("/usr/local/bin/container", forKey: key) + #expect(AppState.storedPath(key) == "/usr/local/bin/container") + UserDefaults.standard.removeObject(forKey: key) + } +} + +// MARK: - Value-type models + +@Suite struct ModelValueTests { + @Test func portAndMountBindingIdentity() { + let p = PortBinding(host: 8080, container: 80, proto: "tcp") + #expect(p.id == "8080:80/tcp") + let m = MountBinding(source: "/host", destination: "/data") + #expect(m.id == "/host->/data") + } + + @Test func networkAndVolumeIdentity() { + #expect(ContainerNetwork(name: "default", subnet: "10.0.0.0/24").id == "default") + let v = ContainerVolume(name: "pg", driver: "local", source: "/v/pg") + #expect(v.id == "pg") + #expect(v.driver == "local") + } + + @Test func stackCountsReflectServiceStatuses() { + let stack = Stack(projectName: "shop", + services: [ctr("a", .running), ctr("b", .running), ctr("c", .stopped)], + origin: .composeLabeled) + #expect(stack.total == 3) + #expect(stack.runningCount == 2) + #expect(stack.id == "shop") + } + + @Test func cpuPercentGuardsBadSamples() { + #expect(cpuPercent(previousUsec: 0, currentUsec: 1_000_000, elapsedSeconds: 1) == 100) + #expect(cpuPercent(previousUsec: 0, currentUsec: 0, elapsedSeconds: 0) == nil) // no elapsed + #expect(cpuPercent(previousUsec: 100, currentUsec: 50, elapsedSeconds: 1) == nil) // counter reset + } +} diff --git a/Package.swift b/Package.swift index 54a2504..146ce29 100644 --- a/Package.swift +++ b/Package.swift @@ -26,10 +26,16 @@ let package = Package( ], path: "ConsaiCore/Sources/ConsaiCore" ), + // App orchestration (AppState + mocks + shell launcher) — UI-free, so it is unit-testable. + .target( + name: "ConsaiKit", + dependencies: ["ConsaiCore"], + path: "ConsaiKit/Sources/ConsaiKit" + ), // The menu bar app (SwiftUI). Bundled into Consai.app by scripts/bundle.sh. .executableTarget( name: "Consai", - dependencies: ["ConsaiCore"], + dependencies: ["ConsaiCore", "ConsaiKit"], path: "App", exclude: ["Info.plist", "Consai.entitlements", "Resources"] ), @@ -38,5 +44,17 @@ let package = Package( dependencies: ["ConsaiCore"], path: "ConsaiCore/Tests/ConsaiCoreTests" ), + .testTarget( + name: "ConsaiKitTests", + dependencies: ["ConsaiKit", "ConsaiCore"], + path: "ConsaiKit/Tests/ConsaiKitTests" + ), + // Native-Swift coverage reporter (replaces the old shell script). There is no hosted + // CI — Apple's container SDK graph can't build on hosted runners — so verification is + // local. After `swift test --enable-code-coverage`, run `swift run coverage` to print + // the llvm-cov report for the logic layers. Depends on Foundation only (no ConsaiCore), + // so `swift run coverage` builds in a second and only shells out to `xcrun` — it never + // re-invokes SwiftPM, so there is no build-lock deadlock. + .executableTarget(name: "coverage", path: "Tools/coverage"), ] ) diff --git a/Tools/coverage/main.swift b/Tools/coverage/main.swift new file mode 100644 index 0000000..ffc9c2e --- /dev/null +++ b/Tools/coverage/main.swift @@ -0,0 +1,53 @@ +import Foundation + +// `swift run coverage` — prints an llvm-cov report for Consai's logic layers. +// +// Run AFTER `swift test --enable-code-coverage` (which writes the profdata + instrumented +// test binary under .build). This tool only shells out to `xcrun llvm-cov`, never to +// `swift`, so it can't deadlock on the SwiftPM build lock the way a command plugin would. +// +// There is no hosted CI for this project (Apple's container SDK graph can't build on hosted +// runners), so coverage is a local step. See docs/TESTING.md. + +let root = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) +let build = root.appendingPathComponent(".build") + +/// Most-recently-modified file named `name` under `dir` (optionally requiring +x). +func newest(_ name: String, under dir: URL, executable: Bool = false) -> URL? { + let fm = FileManager.default + guard let walker = fm.enumerator( + at: dir, includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey] + ) else { return nil } + var best: (url: URL, date: Date)? + for case let url as URL in walker where url.lastPathComponent == name { + let v = try? url.resourceValues(forKeys: [.contentModificationDateKey, .isRegularFileKey]) + guard v?.isRegularFile == true else { continue } + if executable && !fm.isExecutableFile(atPath: url.path) { continue } + let date = v?.contentModificationDate ?? .distantPast + if best == nil || date > best!.date { best = (url, date) } + } + return best?.url +} + +guard let profdata = newest("default.profdata", under: build) else { + FileHandle.standardError.write(Data( + "No coverage data found. Run `swift test --enable-code-coverage` first.\n".utf8)) + exit(1) +} +guard let testBin = newest("ConsaiPackageTests", under: build, executable: true) else { + FileHandle.standardError.write(Data("Could not find the instrumented test binary under .build.\n".utf8)) + exit(1) +} + +let process = Process() +process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") +process.arguments = [ + "llvm-cov", "report", testBin.path, + "-instr-profile", profdata.path, + root.appendingPathComponent("ConsaiCore/Sources").path, + root.appendingPathComponent("ConsaiKit/Sources").path, + "-ignore-filename-regex=(Tests/|MockEngines)", +] +try process.run() +process.waitUntilExit() +exit(process.terminationStatus) diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..9b13ab0 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,66 @@ +# Testing & coverage + +Consai separates code into a **pure, unit-testable logic tier** and a thin **I/O-boundary +tier** that can only be exercised against a live `container` daemon. The two are tested +differently, and coverage should be read per tier — not as a single blended number. + +## Running the tests + +Everything builds and runs **locally** — there is no GitHub Actions / hosted CI (Apple's +`container` SDK can't build on hosted runners, and the project targets a developer's +macOS 26 / Xcode 26 machine). The pre-flight check is a native SwiftPM command plugin +(`Plugins/Check`), not a shell script: + +```bash +swift package --disable-sandbox check # build + test +swift package --disable-sandbox --allow-writing-to-package-directory check coverage # + llvm-cov report +``` + +(`--disable-sandbox` lets the plugin spawn `swift`/`xcrun`; the write flag is needed only +for the `coverage` run, which writes profdata under `.build`.) + +Or invoke the toolchain directly — `swift test` is itself the native test runner: + +```bash +swift test # fast, no daemon required +swift test --enable-code-coverage # adds llvm-cov instrumentation + +PROF=$(find .build -name default.profdata | head -1) +BIN=$(find .build -name ConsaiPackageTests -type f | head -1) +xcrun llvm-cov report "$BIN" -instr-profile "$PROF" ConsaiCore/Sources ConsaiKit/Sources +``` + +## Tier 1 — pure logic (unit-tested here, ~93% line coverage) + +No daemon, no subprocess, no UI. All I/O sits behind protocols, so these are tested with +in-memory fakes and a `SpyProcessRunner` that records argv/cwd instead of spawning. + +| Area | File | Line cov | +|------|------|---------:| +| App orchestration | `ConsaiKit/AppState.swift` | ~93% | +| Stack assembly | `ConsaiCore/Engines/ProjectRegistry.swift` | ~94% | +| Registry persistence | `ConsaiCore/Engines/RegistryStore.swift` | ~93% | +| Service health | `ConsaiCore/Engines/CLIServiceHealth.swift` | ~91% | +| Compose CLI wrapper | `ConsaiCore/Engines/CLIComposeEngine.swift` | ~88% | +| Container creation | `ConsaiCore/Engines/CLIContainerCreator.swift` | ~84% | +| Vitals math | `ConsaiCore/Engines/VitalsSampler.swift` | 100% | +| Models | `ConsaiCore/Models/*` | 90–100% | + +The remaining few percent are defensive fallbacks and synthesized conformances with no +observable behavior to assert. + +## Tier 2 — I/O boundary (integration / E2E, needs a live daemon) + +These wrap the `container` XPC API, spawn real subprocesses, or call AppleScript. They are +deliberately thin (map SDK types → Consai models, build argv, drain pipes) and have no logic +to unit-test in isolation — running them at all requires the `container` daemon and real +processes, so they are covered by the `E2ETests` target and manual QA, not by `swift test`. + +- `SDKContainerEngine`, `SDKImageEngine`, `SDKInfraEngine` — XPC to the daemon +- `LogStreamer` — long-lived `container logs -f` process +- `ProcessRunner` — the actual `Process` spawn + concurrent pipe drain + timeout +- `ContainerShell` — launches Terminal via AppleScript + +Their command-building inputs (argv, parse rules) *are* unit-tested as pure statics +(`pullArguments`, `deleteArguments`, `network*Args`, `parseStatus`, `runArguments`, +`tokenize`, …); only the live wiring is left to E2E.