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.