Files
Jarvis/App/Tests/AppTests/MenuBarIconStateTransitionTests.swift
james.maes a6f91e6b73 fix(bus,hud): v0.1 S1 cluster — webview didFail + menu-bar icon transition (#40, #41, #151)
#40: BridgeNavigationDelegate gains onDidFail closure (covers didFailProvisionalNavigation + didFail). WebviewBridge.failHandshake(reason:) transitions to new terminal .loadFailed state, cancels pending 2s timeout, fires existing alertPresenter, idempotent. AppDelegate.handleWebviewLoadFailure dual-surfaces: failHandshake + critical non-dismissible banner (defense-in-depth across test seams). WebviewBootHealthProbe gets .loadFailed case.

#41: HudStateCoordinator emit closure now ALSO calls menuBarController.transition(to:) in addition to bridge.send(.hudState(...)). Doc-comment correctly notes this is not a HUD-08 single-writer violation — transition writes currentState, not BusOutbound.hudState. scripts/check-single-writer-hudstate.sh still green.

Tests: 4 new Handshake cases (load-failure-from-sentHello with alert-not-duplicated, from-idle, idempotency, post-armed no-op), BridgeNavigationFailureTests end-to-end + per-arm, MenuBarIconStateTransitionTests drives dormantAgentContinuation and asserts .thinking → .speaking → .idle. 62/62 Bus pass; all 18 boundary gates green.
2026-05-13 06:29:34 -05:00

136 lines
5.5 KiB
Swift

import XCTest
import AppKit
import AgentCore
@testable import Jarvis
@testable import Bus
@testable import Config
@testable import Keychain
@testable import Shell
/// Audit-2026-05-12 S1 / #41 regression tests:
///
/// `MenuBarIconController.transition(to:)` is wired through the
/// `HudStateCoordinator` emit closure that drives the bus, so the menu-bar
/// icon animates per agent state (breath/rotate/shimmer/glow). Before this
/// fix the method existed and was unit-tested but had zero production call
/// sites — the icon stayed in `.idle` for the whole app lifetime regardless
/// of agent activity.
///
/// This is NOT a HUD-08 single-writer violation. `transition(to:)` writes
/// the icon's `currentState`, not `BusOutbound.hudState` (whose sole
/// constructor remains the coordinator's existing `bridge.send(.hudState(...))`
/// call). `scripts/check-single-writer-hudstate.sh` covers the invariant.
@MainActor
final class MenuBarIconStateTransitionTests: XCTestCase {
// MARK: - Fakes (mirror existing AppDelegateBusWiringTests pattern)
private struct FakeKeychain: KeychainStore {
let apiKeyStored: Bool
func set(_ value: String, for item: KeychainItem) throws {}
func get(_ item: KeychainItem) throws -> String {
guard apiKeyStored else { throw KeychainError.itemNotFound }
return "sk-ant-fake"
}
func delete(_ item: KeychainItem) throws {}
}
private struct FakeHIDProbe: HIDAccessProbe {
let granted: Bool
func requestListenEventAccess() -> Bool { granted }
}
private struct EntitlementYes: EntitlementGateProbe { func isVerified() -> Bool { true } }
private func makeSnapshots() -> (LaunchSnapshot, PerTurnSnapshot) {
let launchJSON = """
{"schemaVersion":1,"ollama":{"baseURL":"http://127.0.0.1:11434"},"applescript":{"confirmationRequired":true},"toolBlocklist":[],"confirmationPolicy":{"timeoutSeconds":60},"logging":{"fileLevel":"info","osLogLevel":"info"}}
""".data(using: .utf8)!
let perTurnJSON = """
{"schemaVersion":1,"provider":"anthropic","tts":{"tier":"tier1"},"stt":{"whisperKitFallback":false},"featureFlags":{}}
""".data(using: .utf8)!
return (
try! JSONDecoder().decode(LaunchSnapshot.self, from: launchJSON),
try! JSONDecoder().decode(PerTurnSnapshot.self, from: perTurnJSON)
)
}
private func cleanUp(_ delegate: AppDelegate) {
if let item = delegate.statusItem {
NSStatusBar.system.removeStatusItem(item)
}
delegate.wizardController?.close()
}
private func makeDelegate(apiKey: Bool = true) -> AppDelegate {
let delegate = AppDelegate()
delegate.entitlementProbe = EntitlementYes()
delegate.configLoader = { _ in self.makeSnapshots() }
delegate.configWriter = { _ in self.makeSnapshots() }
delegate.keychainStore = FakeKeychain(apiKeyStored: apiKey)
delegate.hidProbe = FakeHIDProbe(granted: true)
delegate.loggingBootstrap = {}
return delegate
}
/// The `HudStateCoordinator` emit closure constructed in `installBus()`
/// fans state changes to BOTH the bus AND `menuBarController.transition(to:)`.
/// Drives the dormant agent-intent continuation through the live
/// coordinator and asserts `menuBarController.state` advances.
func test_menuBarIconAnimatesOnAgentStateChange() async throws {
let delegate = makeDelegate()
delegate.applicationWillFinishLaunching(Notification(name: .init("t")))
defer { cleanUp(delegate) }
guard let coordinator = delegate.hudStateCoordinator else {
XCTFail("HudStateCoordinator must be installed")
return
}
guard let icon = delegate.menuBarController else {
XCTFail("MenuBarIconController must be installed")
return
}
guard let agentCont = delegate.dormantAgentContinuation else {
XCTFail("dormantAgentContinuation must be retained")
return
}
// Arm the handshake so the coordinator promotes off `.booting` —
// otherwise the coordinator pins everything to `.booting` per the
// RESEARCH Open Q #4 boot gate, masking the agent intent.
delegate.webviewBridge?.handleHelloAck(BUS_PROTOCOL_VERSION)
try await Task.sleep(for: .milliseconds(60))
XCTAssertEqual(
coordinator.currentStateForTests,
.idle,
"coordinator must promote off .booting after handshake arms"
)
// Drive a sequence of agent intents and assert the icon follows.
// The icon was initialized to `.idle`; after each intent, the
// coordinator's emit closure routes to `transition(to:)`.
agentCont.yield(.thinking)
try await Task.sleep(for: .milliseconds(80))
XCTAssertEqual(
icon.state,
.thinking,
"menu-bar icon must reach .thinking when AgentHudIntent.thinking fires"
)
agentCont.yield(.speaking)
try await Task.sleep(for: .milliseconds(80))
XCTAssertEqual(
icon.state,
.speaking,
"menu-bar icon must reach .speaking when AgentHudIntent.speaking fires"
)
agentCont.yield(.idle)
try await Task.sleep(for: .milliseconds(80))
XCTAssertEqual(
icon.state,
.idle,
"menu-bar icon must return to .idle when AgentHudIntent.idle fires"
)
}
}