Files
Jarvis/App/Tests/AppTests/BridgeNavigationFailureTests.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

170 lines
6.9 KiB
Swift

import XCTest
import AppKit
import WebKit
@testable import Jarvis
@testable import Bus
@testable import Config
@testable import Keychain
@testable import Shell
/// Audit-2026-05-12 S1 / #40 regression tests:
///
/// `BridgeNavigationDelegate.didFail*` arms surface a HUD-native critical
/// banner + transition the handshake state machine to `.loadFailed` so a
/// missing/stale bundle is never a silent black HUD. Before this fix, the
/// delegate only implemented `didFinish` — a 404 on the JS bundle (or any
/// pre-mount JS error) left the handshake stuck in `.idle` forever and the
/// 2s `HandshakeTiming.timeout` was never armed.
@MainActor
final class BridgeNavigationFailureTests: 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
}
// MARK: - Tests
/// `handleWebviewLoadFailure` transitions the bridge to `.loadFailed` and
/// enqueues a critical banner. Before this fix, a stale bundle reference
/// (e.g. `<script src="./assets/index-XXX.js">` 404) left the handshake
/// stuck in `.idle` forever — `startHandshake` only runs from
/// `didFinish`, and `didFinish` never fires when the page errors before
/// completing.
func test_handleWebviewLoadFailureTransitionsBridgeAndEnqueuesBanner() {
let delegate = makeDelegate()
delegate.applicationWillFinishLaunching(Notification(name: .init("t")))
guard let bridge = delegate.webviewBridge else {
XCTFail("WebviewBridge must be installed by installBus()")
cleanUp(delegate)
return
}
guard let banners = delegate.bannerCoordinator else {
XCTFail("BannerCoordinator must be installed before installBus()")
cleanUp(delegate)
return
}
XCTAssertNil(banners.currentBanner, "no banner before failure")
// Synthesize a representative WKWebView navigation error — domain +
// code mirror what WebKit emits for a missing file:// resource.
let error = NSError(
domain: "NSURLErrorDomain",
code: -1100, // NSURLErrorFileDoesNotExist
userInfo: [NSLocalizedDescriptionKey: "The requested file does not exist."]
)
delegate.handleWebviewLoadFailure(error)
// Bridge state must be terminal `.loadFailed`.
if case .loadFailed(let reason) = bridge.handshakeState {
XCTAssertTrue(reason.contains("NSURLErrorDomain"))
XCTAssertTrue(reason.contains("-1100"))
} else {
XCTFail("expected .loadFailed, got \(bridge.handshakeState)")
}
// A critical banner must be on screen.
XCTAssertEqual(banners.currentBanner?.id, "boot-health-critical-webview")
XCTAssertEqual(
banners.currentBanner?.priority,
1,
"webview load failure is critical-priority (1)"
)
XCTAssertEqual(
banners.currentBanner?.nonDismissible,
true,
"load-failure banner must be non-dismissible (user can't click away a black HUD)"
)
cleanUp(delegate)
}
/// `BridgeNavigationDelegate.didFailProvisionalNavigation` invokes the
/// `onDidFail` closure. Drives the protocol method directly with a
/// synthesized error to verify the arm exists and routes correctly.
func test_bridgeNavigationDelegateDidFailProvisionalRoutesToOnDidFail() {
var captured: Error?
let expectation = self.expectation(description: "onDidFail fires")
let delegate = BridgeNavigationDelegate(
onDidFinish: { XCTFail("didFinish must not fire on failure path") },
onDidFail: { error in
captured = error
expectation.fulfill()
}
)
let error = NSError(domain: "WebKitErrorDomain", code: 102, userInfo: nil)
let webView = WKWebView(frame: .zero)
delegate.webView(webView, didFailProvisionalNavigation: nil, withError: error)
wait(for: [expectation], timeout: 0.5)
XCTAssertEqual((captured as NSError?)?.code, 102)
}
/// `BridgeNavigationDelegate.didFail` (post-provisional) also routes to
/// `onDidFail` — separate arm but same closure target.
func test_bridgeNavigationDelegateDidFailRoutesToOnDidFail() {
var captured: Error?
let expectation = self.expectation(description: "onDidFail fires")
let delegate = BridgeNavigationDelegate(
onDidFinish: { XCTFail("didFinish must not fire on failure path") },
onDidFail: { error in
captured = error
expectation.fulfill()
}
)
let error = NSError(domain: "WebKitErrorDomain", code: 204, userInfo: nil)
let webView = WKWebView(frame: .zero)
delegate.webView(webView, didFail: nil, withError: error)
wait(for: [expectation], timeout: 0.5)
XCTAssertEqual((captured as NSError?)?.code, 204)
}
}