fix(bus,hud): v0.1 S1 cluster — webview didFail + menu-bar icon transition #151

Merged
KofTwentyTwo merged 2 commits from fix/bus-hud-s1-v0.1 into develop 2026-05-13 11:29:34 +00:00
7 changed files with 529 additions and 13 deletions

No files matched your search

+89 -10
View File
@@ -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)
+8
View File
@@ -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"
)
}
}
+12 -3
View File
@@ -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)