Files
Jarvis/packages/Vision/Tests/VisionTests/CameraCaptureMidSessionRevokeTests.swift
james.maes 05411f82ff fix(vision): v0.1 cluster — T2 honesty + pendingFrame leak + TCC revoke observation (#1, #2, #3, #152)
#1 (HIGH): new hasRealT2Provider init param on AgentOrchestrator; t2AvailableForThisTurn now requires both decision.tier != .t3Cloud AND hasRealT2Provider. Production passes false (MissingT2Provider only). Pre-existing escalation test still passes true with mock T2.

#2 (MED): new FrameAttachController.releaseAfterRejectedSubmit() routes through the sole discardFrame() emission site (D-15 single-emission grep gate still green). Both handleChatSubmit and handleChatCancelAndSubmit call it on .rejected.

#3 (MED): AVCaptureSessionWasInterruptedNotification observer yields .midSessionRevoked. macOS limitation surfaced: AVCaptureSessionInterruptionReasonKey is API_UNAVAILABLE(macos), so we cannot discriminate by reason — any interruption surfaces the banner. NSApplication.didBecomeActive observer calls becameAuthorized() on non-authorized → .authorized flip.

Vision 75 + AgentCore 243 tests pass; all 18 boundary gates green.
2026-05-13 06:30:09 -05:00

71 lines
2.9 KiB
Swift

import XCTest
import AVFoundation
@testable import JarvisVision
/// Audit 2026-05-12 F-V3 — mid-session TCC revoke observation.
///
/// Before the fix, `CameraCapture.startWatchers()` only observed
/// `AVCaptureSessionRuntimeError`. macOS Sequoia / 26 Tahoe surface
/// mid-session Camera TCC revoke via `AVCaptureSessionWasInterrupted`,
/// not `RuntimeError`. Without an interruption observer, a user revoking
/// camera access via System Settings while the app was running never
/// drove the `cameraRevoked` HUD banner — the producer signal never fired.
///
/// **macOS quirk:** `AVCaptureSessionInterruptionReasonKey` and the
/// associated `InterruptionReason` enum are `API_UNAVAILABLE(macos)` per
/// AVFoundation headers (iOS / macCatalyst / tvOS / visionOS only). So
/// on macOS the observer cannot discriminate reasons; any interruption
/// means we can't capture, and the user should see the revoked banner.
final class CameraCaptureMidSessionRevokeTests: XCTestCase {
/// Posting an `AVCaptureSessionWasInterrupted` against the live session
/// must drive the degradation stream to yield `.midSessionRevoked`.
/// Without the observer this notification would arrive silently.
func testInterruptionYieldsMidSessionRevoked() async throws {
let (stream, cont) = AsyncStream<VisionDegradationReason>.makeStream()
let capture = CameraCapture(degradationContinuation: cont, degradationStream: stream)
await capture.setAuthStatusProbe { .authorized }
do {
try await capture.open()
} catch VisionError.noCameraDevice {
try XCTSkipIf(true, "no camera device available — see Track-C 6 hardware test")
return
} catch {
try XCTSkipIf(true, "camera open failed (\(error)) — see Track-C 6 hardware test")
return
}
try await capture.simulateInterruption()
let reason = await Self.firstDeg(stream: stream, timeout: 1.0)
XCTAssertEqual(reason, .midSessionRevoked,
"AVCaptureSession.wasInterruptedNotification must yield .midSessionRevoked")
await capture.shutdown()
}
/// Race the next event off the stream against a sleep so the test does
/// not hang when no event will ever arrive. Mirrors the helper in
/// `CameraCaptureTCCTests`.
private static func firstDeg(
stream: AsyncStream<VisionDegradationReason>,
timeout: Double
) async -> VisionDegradationReason? {
let collector = Task { () -> VisionDegradationReason? in
for await reason in stream {
return reason
}
return nil
}
let timer = Task { () -> VisionDegradationReason? in
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
collector.cancel()
return nil
}
let result = await collector.value
timer.cancel()
return result
}
}