fix(bus,hud): v0.1 S1 cluster — webview didFail + menu-bar icon transition #151
No files matched your search
+89
-10
@@ -2706,10 +2706,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
// Navigation delegate: fires handshake on first didFinish.
|
||||
let navDelegate = BridgeNavigationDelegate { [weak self] in
|
||||
self?.webviewBridge?.startHandshake()
|
||||
}
|
||||
// Navigation delegate: fires handshake on first didFinish; surfaces
|
||||
// load failures (audit-2026-05-12 S1 / #40) via the bus + a critical
|
||||
// banner so a missing/stale bundle is never a silent black HUD.
|
||||
let navDelegate = BridgeNavigationDelegate(
|
||||
onDidFinish: { [weak self] in
|
||||
self?.webviewBridge?.startHandshake()
|
||||
},
|
||||
onDidFail: { [weak self] error in
|
||||
self?.handleWebviewLoadFailure(error)
|
||||
}
|
||||
)
|
||||
bridgeNavigationDelegate = navDelegate
|
||||
panel.webView.navigationDelegate = navDelegate
|
||||
|
||||
@@ -2726,11 +2733,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
// closure bridges App.HudState → Bus.HudState (rawValue round-trip)
|
||||
// and dispatches to the webview bridge on the MainActor. `[weak
|
||||
// bridge]` avoids a retain cycle with `self` via the bridge.
|
||||
let coordinator = HudStateCoordinator(emit: { [weak bridge] appState in
|
||||
guard let bridge else { return }
|
||||
Task { @MainActor in
|
||||
try? await bridge.send(.hudState(busHudState(from: appState)))
|
||||
//
|
||||
// Audit-2026-05-12 S1 / #41 — the emit closure ALSO fans state to
|
||||
// `MenuBarIconController.transition(to:)` so the menu-bar icon
|
||||
// animates per agent state (breath/rotate/shimmer/glow). Before
|
||||
// this fan-out, `transition(to:)` was production-dead: the method
|
||||
// existed and was unit-tested, but no production call site invoked
|
||||
// it, so 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`, which has only one constructor
|
||||
// (`bridge.send(.hudState(...))` below).
|
||||
let coordinator = HudStateCoordinator(emit: { [weak bridge, weak self] appState in
|
||||
if let bridge {
|
||||
Task { @MainActor in
|
||||
try? await bridge.send(.hudState(busHudState(from: appState)))
|
||||
}
|
||||
}
|
||||
self?.menuBarController?.transition(to: appState)
|
||||
})
|
||||
|
||||
// Dormant producer streams. Phase 4 replaces `agentStream` with the
|
||||
@@ -2816,6 +2836,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
panel.webView.loadFileURL(entryURL, allowingReadAccessTo: resourcesDir)
|
||||
}
|
||||
|
||||
/// Audit-2026-05-12 S1 / #40 — webview navigation failure handler.
|
||||
/// Routes `didFailProvisionalNavigation` and `didFail` from
|
||||
/// `BridgeNavigationDelegate` into:
|
||||
///
|
||||
/// 1. `WebviewBridge.failHandshake(reason:)` — transitions the
|
||||
/// handshake state machine to `.loadFailed` and fires the same
|
||||
/// `alertPresenter` (TCCAlertService.presentHardBlock) the
|
||||
/// `.timedOut` and `.mismatched` paths already use.
|
||||
/// 2. A critical-severity banner via `HUDBannerCoordinator` so the
|
||||
/// user sees a HUD-native error surface (matching the pattern
|
||||
/// `.bootHealthCritical` uses for memory-off / no-API-key /
|
||||
/// no-MCP-tools / webview-never-armed failures).
|
||||
///
|
||||
/// Both surfaces fire — the alert is the hard-block path that already
|
||||
/// terminates the app on handshake-mismatch / timeout; the banner is
|
||||
/// a defense-in-depth render path that survives even if the alert is
|
||||
/// suppressed (e.g., AppDelegate test seam with a silent
|
||||
/// `alertPresenter`).
|
||||
@MainActor
|
||||
func handleWebviewLoadFailure(_ error: Error) {
|
||||
let nsError = error as NSError
|
||||
let reason = "\(nsError.domain) \(nsError.code): \(nsError.localizedDescription)"
|
||||
systemLogger?.critical("webview navigation failed — surfacing as load-failed: \(reason)")
|
||||
webviewBridge?.failHandshake(reason: reason)
|
||||
bannerCoordinator?.enqueue(.bootHealthCritical(
|
||||
subsystem: "webview",
|
||||
reason: "HUD bundle failed to load (\(reason))"
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - Wizard
|
||||
|
||||
/// `BannerSink` adapter so `InputMonitoringProbe` (Shell) can enqueue onto
|
||||
@@ -2984,14 +3034,43 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
/// the main actor via `MainActor.assumeIsolated` — the same Swift 6 pattern
|
||||
/// `WebviewBridge` uses for `WKScriptMessageHandlerWithReply`.
|
||||
@MainActor
|
||||
private final class BridgeNavigationDelegate: NSObject, WKNavigationDelegate {
|
||||
final class BridgeNavigationDelegate: NSObject, WKNavigationDelegate {
|
||||
let onDidFinish: @MainActor () -> Void
|
||||
init(onDidFinish: @escaping @MainActor () -> Void) {
|
||||
/// Audit-2026-05-12 S1 / #40 — invoked on either
|
||||
/// `didFailProvisionalNavigation` or `didFail`. Without this hook the
|
||||
/// 2s handshake timeout never arms (it's scheduled inside
|
||||
/// `startHandshake`, which only runs on `didFinish`), so a bundle-404
|
||||
/// or JS-parse-error before mount left the HUD a silent black square.
|
||||
let onDidFail: @MainActor (Error) -> Void
|
||||
init(
|
||||
onDidFinish: @escaping @MainActor () -> Void,
|
||||
onDidFail: @escaping @MainActor (Error) -> Void
|
||||
) {
|
||||
self.onDidFinish = onDidFinish
|
||||
self.onDidFail = onDidFail
|
||||
}
|
||||
nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
MainActor.assumeIsolated { onDidFinish() }
|
||||
}
|
||||
/// Provisional navigation = "the server / file URL didn't even start
|
||||
/// loading" (404 on `file://`, missing index.html, DNS / sandbox
|
||||
/// rejection). The WKError comes via `error`.
|
||||
nonisolated func webView(
|
||||
_ webView: WKWebView,
|
||||
didFailProvisionalNavigation navigation: WKNavigation!,
|
||||
withError error: Error
|
||||
) {
|
||||
MainActor.assumeIsolated { onDidFail(error) }
|
||||
}
|
||||
/// Post-provisional navigation failure — the page started loading and
|
||||
/// then errored (subresource 404 mid-stream, decode error after a 200).
|
||||
nonisolated func webView(
|
||||
_ webView: WKWebView,
|
||||
didFail navigation: WKNavigation!,
|
||||
withError error: Error
|
||||
) {
|
||||
MainActor.assumeIsolated { onDidFail(error) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Voice subsystem adapters (Plan 06-05)
|
||||
|
||||
@@ -492,6 +492,14 @@ public struct WebviewBootHealthProbe: BootHealthProbe {
|
||||
status: .failed(reason: "JS never acked hello — webview bundle missing or crashed at startup", severity: .critical),
|
||||
evidence: "HandshakeState.timedOut after 2s"
|
||||
)
|
||||
case .loadFailed(let reason):
|
||||
// Audit-2026-05-12 S1 / #40 — bundle navigation failed before
|
||||
// `didFinish` could fire. Same severity as `.timedOut`: the
|
||||
// user sees no HUD until the bundle is rebuilt.
|
||||
return ProbeOutcome(
|
||||
status: .failed(reason: "webview bundle load failed: \(reason); rebuild webview bundle", severity: .critical),
|
||||
evidence: "HandshakeState.loadFailed(\(reason))"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,25 @@ import Foundation
|
||||
/// `.idle` → `.sentHello(deadline:)` → `.armed`
|
||||
/// ↘ `.mismatched(swift:, js:)`
|
||||
/// ↘ `.timedOut`
|
||||
/// ↘ `.loadFailed(reason:)`
|
||||
///
|
||||
/// Once the state reaches `.armed`, `.mismatched`, or `.timedOut`, it does
|
||||
/// not move. Mismatch and timeout are terminal — the HUD bundle must be
|
||||
/// rebuilt.
|
||||
/// Once the state reaches `.armed`, `.mismatched`, `.timedOut`, or
|
||||
/// `.loadFailed`, it does not move. Mismatch, timeout, and load-failure are
|
||||
/// terminal — the HUD bundle must be rebuilt or the navigation error fixed.
|
||||
///
|
||||
/// `.loadFailed` covers the bundle-navigation failure path (audit-2026-05-12
|
||||
/// S1, issue #40): if `index.html`, the JS bundle, or any sub-resource
|
||||
/// 404s / fails to parse, `didFinish` never fires, so `startHandshake`'s 2s
|
||||
/// timeout is never armed. `BridgeNavigationDelegate.didFail*` arms route
|
||||
/// to `WebviewBridge.failHandshake(reason:)` which transitions to this
|
||||
/// state and surfaces the error to the user.
|
||||
public enum HandshakeState: Equatable, Sendable {
|
||||
case idle
|
||||
case sentHello(deadline: Date)
|
||||
case armed
|
||||
case mismatched(swift: String, js: String)
|
||||
case timedOut
|
||||
case loadFailed(reason: String)
|
||||
}
|
||||
|
||||
/// Timing parameters for the handshake. 2s is the RESEARCH §HUD-05 budget —
|
||||
|
||||
@@ -141,6 +141,35 @@ public final class WebviewBridge: NSObject {
|
||||
|
||||
// MARK: - Handshake surface
|
||||
|
||||
/// Audit-2026-05-12 S1 / #40 — surfaces a navigation-layer failure
|
||||
/// (bundle 404, JS parse error before mount, etc.) where `didFinish`
|
||||
/// will never fire and `startHandshake` would otherwise never run.
|
||||
///
|
||||
/// Idempotent: only transitions on the first call (subsequent calls
|
||||
/// no-op, matching `handleTimeout`'s idempotency guard). Cancels any
|
||||
/// in-flight timeout Task. Routes through the same `alertPresenter`
|
||||
/// surface as `.timedOut` and `.mismatched` so production wiring
|
||||
/// (`TCCAlertService.presentHardBlock` + AppDelegate's banner
|
||||
/// coordinator enqueue) lights up consistently across all three
|
||||
/// terminal-failure paths.
|
||||
public func failHandshake(reason: String) {
|
||||
// Idempotent: once we've reached a terminal state, stay there.
|
||||
switch handshakeState {
|
||||
case .armed, .mismatched, .timedOut, .loadFailed:
|
||||
return
|
||||
case .idle, .sentHello:
|
||||
break
|
||||
}
|
||||
timeoutTask?.cancel()
|
||||
timeoutTask = nil
|
||||
logger.critical("bus handshake load-failed: \(reason)")
|
||||
handshakeState = .loadFailed(reason: reason)
|
||||
alertPresenter(
|
||||
"Jarvis HUD couldn't start",
|
||||
"The HUD bundle failed to load: \(reason). Rebuild Jarvis from source."
|
||||
)
|
||||
}
|
||||
|
||||
/// Transitions to `.sentHello`, schedules the 2s timeout, and sends the
|
||||
/// `hello` frame over the outbound path. Called by `AppDelegate` on
|
||||
/// `WKNavigationDelegate.webView(_:didFinish:)`.
|
||||
|
||||
@@ -103,6 +103,93 @@ final class HandshakeTests: XCTestCase {
|
||||
XCTAssertEqual(alerts.count, 0, "armed handshake must suppress timeout alert")
|
||||
}
|
||||
|
||||
// MARK: - Audit-2026-05-12 S1 / #40 — failHandshake(reason:)
|
||||
|
||||
/// `failHandshake` from `.sentHello` transitions to `.loadFailed`, cancels
|
||||
/// the pending 2s timeout, and fires exactly one alert.
|
||||
func test_failHandshakeTransitionsToLoadFailedAndAlerts() async {
|
||||
let alerts = AlertCollector()
|
||||
let bridge = makeBridge(alertCollector: alerts)
|
||||
|
||||
bridge.startHandshake()
|
||||
bridge.failHandshake(reason: "bundle 404: assets/index-XYZ.js")
|
||||
|
||||
XCTAssertEqual(
|
||||
bridge.handshakeState,
|
||||
.loadFailed(reason: "bundle 404: assets/index-XYZ.js")
|
||||
)
|
||||
XCTAssertEqual(alerts.count, 1, "exactly one alert on load failure")
|
||||
XCTAssertEqual(alerts.titles.first, "Jarvis HUD couldn't start")
|
||||
XCTAssertTrue(
|
||||
alerts.bodies.first?.contains("assets/index-XYZ.js") ?? false,
|
||||
"alert body should mention the underlying reason"
|
||||
)
|
||||
|
||||
// The cancelled timeout must not fire — wait past the 2s deadline
|
||||
// and confirm no second alert lands.
|
||||
try? await Task.sleep(for: .milliseconds(2100))
|
||||
XCTAssertEqual(
|
||||
alerts.count,
|
||||
1,
|
||||
"cancelled timeout must not surface a second alert"
|
||||
)
|
||||
if case .loadFailed = bridge.handshakeState {
|
||||
// ok
|
||||
} else {
|
||||
XCTFail("state must remain .loadFailed past 2s deadline, got \(bridge.handshakeState)")
|
||||
}
|
||||
}
|
||||
|
||||
/// `failHandshake` from `.idle` (before `startHandshake`) is still valid —
|
||||
/// the navigation can fail before `didFinish` would have been called.
|
||||
func test_failHandshakeFromIdleTransitions() {
|
||||
let alerts = AlertCollector()
|
||||
let bridge = makeBridge(alertCollector: alerts)
|
||||
|
||||
XCTAssertEqual(bridge.handshakeState, .idle)
|
||||
bridge.failHandshake(reason: "provisional nav failed: -1100")
|
||||
|
||||
XCTAssertEqual(
|
||||
bridge.handshakeState,
|
||||
.loadFailed(reason: "provisional nav failed: -1100")
|
||||
)
|
||||
XCTAssertEqual(alerts.count, 1)
|
||||
}
|
||||
|
||||
/// `failHandshake` is idempotent — once in a terminal state, calling
|
||||
/// again no-ops (no second alert, no state change).
|
||||
func test_failHandshakeIsIdempotent() {
|
||||
let alerts = AlertCollector()
|
||||
let bridge = makeBridge(alertCollector: alerts)
|
||||
|
||||
bridge.failHandshake(reason: "first")
|
||||
bridge.failHandshake(reason: "second")
|
||||
|
||||
XCTAssertEqual(
|
||||
bridge.handshakeState,
|
||||
.loadFailed(reason: "first"),
|
||||
"subsequent failHandshake calls must not overwrite the first reason"
|
||||
)
|
||||
XCTAssertEqual(alerts.count, 1, "subsequent failHandshake calls must not re-alert")
|
||||
}
|
||||
|
||||
/// `failHandshake` after `.armed` no-ops — once the handshake succeeds,
|
||||
/// a late navigation-failure callback (which would normally never fire)
|
||||
/// must not transition out of `.armed`.
|
||||
func test_failHandshakeAfterArmedNoOps() {
|
||||
let alerts = AlertCollector()
|
||||
let bridge = makeBridge(alertCollector: alerts)
|
||||
|
||||
bridge.startHandshake()
|
||||
bridge.handleHelloAck(BUS_PROTOCOL_VERSION)
|
||||
XCTAssertEqual(bridge.handshakeState, .armed)
|
||||
|
||||
bridge.failHandshake(reason: "late didFail")
|
||||
|
||||
XCTAssertEqual(bridge.handshakeState, .armed, "armed handshake must not regress to .loadFailed")
|
||||
XCTAssertEqual(alerts.count, 0)
|
||||
}
|
||||
|
||||
func test_startHandshakeMovesToSentHello() {
|
||||
let alerts = AlertCollector()
|
||||
let bridge = makeBridge(alertCollector: alerts)
|
||||
|
||||
Reference in new issue
Block a user