import SwiftUI import XCTest @_spi(Fixtures) @testable import CallsKit /// The screen watchdogs (ScreenWatchdog.swift): the sharer's, which puts /// its own screen right, and the receiver's, which says when someone /// else's has stopped. Written for Paul's report of 2026-09-28. final class ScreenWatchdogTests: XCTestCase { private func counts(_ beats: UInt64, _ delivered: UInt64) -> ScreenCaptureCounts { ScreenCaptureCounts(heartbeats: beats, delivered: delivered) } private func sending(_ n: UInt64) -> [String: ScreenSendCounts] { ["vicky": ScreenSendCounts(encoded: n, sent: n, keyframes: 1)] } func testAHealthyShareAsksForNothing() { var dog = ScreenWatchdog() for t in 0..<30 { let n = UInt64(t * 30) XCTAssertEqual(dog.observe(at: Double(t), capture: counts(n, n), sending: sending(n), stalledBy: []), []) } XCTAssertEqual(dog.status, "watching") XCTAssertFalse(dog.recovering) } func testAStillScreenIsNotAStall() { // ScreenCaptureKit reports a still screen as idle, and the capture // repeats the last frame once a second: heartbeats keep coming and // frames trickle, encoded as they go. var dog = ScreenWatchdog() for t in 0..<30 { let actions = dog.observe( at: Double(t), capture: counts(UInt64(t * 30), UInt64(t)), sending: sending(UInt64(t)), stalledBy: []) XCTAssertEqual(actions, []) } } func testACaptureThatStopsReportingIsRestartedWithAGrowingWait() { var dog = ScreenWatchdog() var restarts: [Int] = [] for t in 0..<40 { let beats: UInt64 = t < 5 ? UInt64(t * 30) : 120 let actions = dog.observe(at: Double(t), capture: counts(beats, beats), sending: sending(beats), stalledBy: []) if actions.contains(.restartCapture) { restarts.append(t) } } // Stalled from t=4 on: first restart three seconds later, then 5, 10, 20 s apart. XCTAssertEqual(restarts, [7, 12, 22]) XCTAssertTrue(dog.captureStalled) XCTAssertEqual(dog.captureRestarts, 3) // And it comes back. XCTAssertEqual(dog.observe(at: 40, capture: counts(500, 500), sending: sending(500), stalledBy: []), []) XCTAssertFalse(dog.captureStalled) } func testACaptureThatCannotTellIdleFromStalledIsNotWatched() { // A ReplayKit broadcast sends nothing for a still screen. var dog = ScreenWatchdog() for t in 0..<20 { let actions = dog.observe( at: Double(t), capture: ScreenCaptureCounts(heartbeats: nil, delivered: 7), sending: sending(7), stalledBy: []) XCTAssertEqual(actions, []) } } func testAnEncoderThatStopsGetsAKeyframeThenAFreshEncoderThenANewTransceiver() { var dog = ScreenWatchdog() var log: [(Int, ScreenRecovery)] = [] for t in 0..<47 { let delivered = UInt64(t * 30) let encoded: UInt64 = t < 5 ? delivered : 120 for a in dog.observe(at: Double(t), capture: counts(delivered, delivered), sending: sending(encoded), stalledBy: []) { log.append((t, a)) } } XCTAssertEqual(log.map(\.1), [.keyframe("vicky"), .recreateEncoder("vicky"), .reAdd("vicky"), .keyframe("vicky")]) // Five seconds after it stopped (t=4), three seconds apart, then a // rest of thirty seconds before starting over. // (not before the connection's first ten seconds are up) XCTAssertEqual(log.map(\.0), [10, 13, 16, 46]) XCTAssertEqual(dog.keyframes, 2) XCTAssertEqual(dog.encoderRecoveries, 1) XCTAssertEqual(dog.reAdds, 1) } func testAnEncoderStoppedForOnePersonIsMendedForThemAlone() { // Each connection has an encoder of its own: the browser's (H.264 at // the level Chromium answers) can refuse frames the app's (High 5.2) // takes. The steps go to the stopped one only. var dog = ScreenWatchdog() var log: [(Int, ScreenRecovery)] = [] for t in 0..<20 { let delivered = UInt64(t * 30) let sending = [ "browser": ScreenSendCounts(encoded: t < 5 ? delivered : 120, sent: t < 5 ? delivered : 120, keyframes: 1), "app": ScreenSendCounts(encoded: delivered, sent: delivered, keyframes: 1), ] for a in dog.observe(at: Double(t), capture: counts(delivered, delivered), sending: sending, stalledBy: []) { log.append((t, a)) } } XCTAssertEqual(log.map(\.1), [.keyframe("browser"), .recreateEncoder("browser"), .reAdd("browser")]) } func testAnEncoderThatRecoversAfterTheKeyframeIsLeftAlone() { var dog = ScreenWatchdog() var actions: [ScreenRecovery] = [] for t in 0..<30 { let delivered = UInt64(t * 30) let encoded: UInt64 = (5..<11).contains(t) ? 120 : delivered actions += dog.observe(at: Double(t), capture: counts(delivered, delivered), sending: sending(encoded), stalledBy: []) } XCTAssertEqual(actions, [.keyframe("vicky")]) XCTAssertFalse(dog.recovering) } func testNothingEncodedInTheFirstTenSecondsIsAWarmUp() { var dog = ScreenWatchdog() for t in 0..<10 { let delivered = UInt64(t * 30) XCTAssertEqual(dog.observe(at: Double(t), capture: counts(delivered, delivered), sending: sending(1), stalledBy: []), []) } XCTAssertEqual(dog.observe(at: 10, capture: counts(300, 300), sending: sending(1), stalledBy: []), [.keyframe("vicky")]) } func testALinkThatHoldsTheScreenBackIsNotAnEncoderStall() { // The estimate collapses and WebRTC stops encoding for a while: the // link's business, and a smaller encoder would only stay smaller. var dog = ScreenWatchdog() for t in 0..<20 { let delivered = UInt64(t * 30) let encoded: UInt64 = t < 5 ? delivered : 120 let counts = ["vicky": ScreenSendCounts(encoded: encoded, sent: encoded, keyframes: 1, bandwidthLimited: t >= 5)] XCTAssertEqual(dog.observe(at: Double(t), capture: self.counts(delivered, delivered), sending: counts, stalledBy: []), []) } } func testNothingGoingInIsNotAnEncoderStall() { // No frames delivered (a broadcast of a still screen): nothing to encode. var dog = ScreenWatchdog() for t in 0..<20 { let actions = dog.observe( at: Double(t), capture: ScreenCaptureCounts(heartbeats: nil, delivered: 50), sending: sending(50), stalledBy: []) XCTAssertEqual(actions, []) } } func testAReceiversReportWhileTheScreenLeavesNormallyGetsKeyframesFirstAndNeverAShrink() { var dog = ScreenWatchdog() var log: [(Int, ScreenRecovery)] = [] for t in 0..<20 { let n = UInt64(t * 30) let stalledBy: Set = t >= 3 ? ["vicky"] : [] for a in dog.observe(at: Double(t), capture: counts(n, n), sending: sending(n), stalledBy: stalledBy) { log.append((t, a)) } } let keyframes = log.filter { $0.1 == .keyframe("vicky") } XCTAssertEqual(keyframes.map(\.0), [3, 7, 11]) // What comes after the keyframes (spec §15.22) is a rebuild at the // same size, never a smaller encoder. XCTAssertFalse(log.contains { $0.1 == .recreateEncoder("vicky") }) XCTAssertEqual(log.first?.1, .keyframe("vicky")) XCTAssertEqual(dog.stallReports, 1, "one report, however long it lasts") XCTAssertTrue(dog.recovering) } func testAReceiversReportWhileTheScreenHasStoppedHereTooGoesFurther() { var dog = ScreenWatchdog() var actions: [ScreenRecovery] = [] for t in 0..<30 { let delivered = UInt64(t * 30) let encoded: UInt64 = t < 5 ? delivered : 120 actions += dog.observe( at: Double(t), capture: counts(delivered, delivered), sending: sending(encoded), stalledBy: t >= 6 ? ["vicky"] : []) } XCTAssertTrue(actions.contains(.recreateEncoder("vicky"))) } func testAReportThatClearsEndsTheRecovery() { var dog = ScreenWatchdog() var actions: [ScreenRecovery] = [] for t in 0..<20 { let n = UInt64(t * 30) let stalledBy: Set = (3..<5).contains(t) ? ["vicky"] : [] actions += dog.observe(at: Double(t), capture: counts(n, n), sending: sending(n), stalledBy: stalledBy) } XCTAssertEqual(actions, [.keyframe("vicky")]) XCTAssertFalse(dog.recovering) XCTAssertTrue(dog.status.hasPrefix("watching, 1 recovery")) } func testAReAddedTransceiverStartingFromZeroIsNotAStall() { var dog = ScreenWatchdog() for t in 0..<10 { let n = UInt64(t * 30) XCTAssertEqual(dog.observe(at: Double(t), capture: counts(n, n), sending: sending(n), stalledBy: []), []) } // The connection's counters start again at zero on a new transceiver. for t in 10..<20 { let n = UInt64(t * 30) let fresh = UInt64((t - 10) * 30) XCTAssertEqual(dog.observe(at: Double(t), capture: counts(n, n), sending: sending(fresh), stalledBy: []), []) } } // MARK: - A receiver that keeps reporting (spec §15.22) private func escalating( _ range: Range, stalledBy: (Int) -> Set, sent: (Int) -> [String: ScreenSendCounts]? = { _ in nil }, dog: inout ScreenWatchdog ) -> [(Int, ScreenRecovery)] { var log: [(Int, ScreenRecovery)] = [] for t in range { let n = UInt64(t * 30) for a in dog.observe(at: Double(t), capture: counts(n, n), sending: sent(t) ?? sending(n), stalledBy: stalledBy(t)) { log.append((t, a)) } } return log } func testAReportKeyframesDoNotAnswerIsRebuiltThenSharedAgainThenGivenUp() { var dog = ScreenWatchdog() let log = escalating(0..<45, stalledBy: { $0 >= 3 ? ["vicky"] : [] }, dog: &dog) let steps = log.filter { if case .keyframe = $0.1 { return false } else { return true } } // Reported from 3: keyframes at once; six seconds on, still reported, // the connection is rebuilt; ten more, everyone's screen shared again; // ten more, given up. XCTAssertEqual(steps.map(\.1), [.rebuild("vicky"), .reshare]) XCTAssertEqual(steps.map(\.0), [9, 19]) XCTAssertEqual(dog.escalation(of: "vicky"), .failed) XCTAssertEqual(dog.rebuilds, 1) XCTAssertEqual(dog.reshares, 1) XCTAssertEqual(dog.failures, 1) // A report alone never shrinks the screen or recreates its encoder: // Text and detail stays Text and detail. XCTAssertFalse(log.contains { if case .recreateEncoder = $0.1 { return true } else { return false } }) XCTAssertEqual(dog.status, "a receiver's view could not be restored") } func testAFailureStaysUntilTheReportClears() { var dog = ScreenWatchdog() _ = escalating(0..<45, stalledBy: { $0 >= 3 ? ["vicky"] : [] }, dog: &dog) let later = escalating(45..<120, stalledBy: { $0 < 100 ? ["vicky"] : [] }, dog: &dog) XCTAssertFalse(later.contains { $0.1 == .reshare || $0.1 == .rebuild("vicky") }, "nothing more is tried") XCTAssertEqual(dog.escalation(of: "vicky"), .none, "and a view that comes back is forgotten") } func testARebuildThatWorksEndsIt() { var dog = ScreenWatchdog() let log = escalating(0..<60, stalledBy: { (3..<12).contains($0) ? ["vicky"] : [] }, dog: &dog) let steps = log.filter { if case .keyframe = $0.1 { return false } else { return true } } XCTAssertEqual(steps.map(\.1), [.rebuild("vicky")]) XCTAssertEqual(dog.escalation(of: "vicky"), .none) XCTAssertEqual(dog.reshares, 0) } func testRebuildsAndSharesAgainAreRationed() { var dog = ScreenWatchdog() // First stall: rebuilt at 9, and that works. _ = escalating(0..<20, stalledBy: { (3..<12).contains($0) ? ["vicky"] : [] }, dog: &dog) // A second, within the minute: no rebuild so soon, so straight to a // share again; that works too. let second = escalating(20..<50, stalledBy: { (25..<34).contains($0) ? ["vicky"] : [] }, dog: &dog) XCTAssertEqual(second.filter { $0.1 == .reshare || $0.1 == .rebuild("vicky") }.map(\.1), [.reshare]) // A third, past the minute but inside five: rebuilt, and then there is // no share again to be had, so it is given up. let third = escalating(80..<120, stalledBy: { $0 >= 85 ? ["vicky"] : [] }, dog: &dog) XCTAssertEqual(third.filter { $0.1 == .reshare || $0.1 == .rebuild("vicky") }.map(\.1), [.rebuild("vicky")]) XCTAssertEqual(dog.escalation(of: "vicky"), .failed) } func testNothingSentIsNothingToEscalate() { // A still screen with nothing new to send, or a link holding it all // back: the receiver cannot be failing to decode frames that never // left, so keyframes are all it gets. var dog = ScreenWatchdog() var log: [(Int, ScreenRecovery)] = [] for t in 0..<40 { // Reporting all along (idle reports), nothing new delivered or sent // after t=3. let n = UInt64(min(t, 3) * 30) let still = ["vicky": ScreenSendCounts(encoded: n, sent: n, keyframes: 1)] for a in dog.observe( at: Double(t), capture: counts(UInt64(t * 30), n), sending: still, stalledBy: t >= 3 ? ["vicky"] : []) { log.append((t, a)) } } XCTAssertFalse(log.isEmpty) XCTAssertTrue(log.allSatisfy { if case .keyframe = $0.1 { return true } else { return false } }, "\(log)") XCTAssertEqual(dog.escalation(of: "vicky"), .keyframes(since: 3)) } func testARebuildThatNeverComesBackStillEndsInWords() { // The rebuilt connection never negotiates a screen again: there is // nothing to count, and the steps go on regardless. var dog = ScreenWatchdog() let log = escalating( 0..<45, stalledBy: { $0 >= 3 ? ["vicky"] : [] }, sent: { $0 > 9 ? [:] : nil }, dog: &dog) XCTAssertTrue(log.contains { $0.1 == .reshare }) XCTAssertEqual(dog.escalation(of: "vicky"), .failed) } func testOneShareAgainAnswersEveryReport() { var dog = ScreenWatchdog() var log: [(Int, ScreenRecovery)] = [] for t in 0..<30 { let n = UInt64(t * 30) let both = ["vicky": ScreenSendCounts(encoded: n, sent: n, keyframes: 1), "tony": ScreenSendCounts(encoded: n, sent: n, keyframes: 1)] let stalled: Set = t >= 3 ? ["vicky", "tony"] : [] for a in dog.observe(at: Double(t), capture: counts(n, n), sending: both, stalledBy: stalled) { log.append((t, a)) } } XCTAssertEqual(log.filter { $0.1 == .reshare }.count, 1) XCTAssertEqual(log.filter { if case .rebuild = $0.1 { return true } else { return false } }.count, 2) } func testTheNoticeMovesOnInWords() { XCTAssertEqual(ScreenWords.notice(recovering: [], restarting: [], failed: []), "") XCTAssertEqual( ScreenWords.notice(recovering: ["Samuel"], restarting: [], failed: []), "Samuel\u{2019}s view of your screen stalled \u{2014} recovering") XCTAssertEqual( ScreenWords.notice(recovering: [], restarting: ["Samuel"], failed: []), "Restarting your share for Samuel\u{2026}") XCTAssertEqual( ScreenWords.notice(recovering: [], restarting: [], failed: ["Samuel"]), "Couldn\u{2019}t restore Samuel\u{2019}s view \u{2014} try Stop and Share again") XCTAssertEqual( ScreenWords.notice(recovering: ["Tony"], restarting: [], failed: ["Samuel"]), "Couldn\u{2019}t restore Samuel\u{2019}s view \u{2014} try Stop and Share again. Tony\u{2019}s view of your screen stalled \u{2014} recovering") XCTAssertEqual( ScreenWords.notice(recovering: [], restarting: ["A", "B", "C"], failed: []), "Restarting your share for 3 people\u{2026}") } func testTheReceiversNumbersNameTheStall() { var before = ScreenReceiveReport() before.mid = "3" before.packetsReceived = 1000 before.framesReceived = 300 before.framesDecoded = 300 var wedged = before wedged.packetsReceived = 1400 wedged.framesReceived = 420 XCTAssertEqual(wedged.diagnosis(since: before), "frames arriving, none decoded (the decoder)") XCTAssertTrue(wedged.moved(since: before).hasPrefix("packets +400, frames received +120, decoded +0")) XCTAssertEqual(before.diagnosis(since: before), "no packets arriving (the sender or the path)") var rebuilt = wedged rebuilt.mid = "5" XCTAssertEqual(rebuilt.diagnosis(since: before), "no earlier reading") } // MARK: - The receiver func testAScreenThatStopsMovingIsReportedAfterFourSecondsAndForThreeMinutesAtMost() { var watch = ScreenReceiveWatch() var reported: [Int] = [] for t in 0..<200 { let decoded: UInt64 = t < 10 ? UInt64(t * 10) : 90 if !watch.observe(at: Double(t), decoded: ["paul": decoded]).isEmpty { reported.append(t) } } XCTAssertEqual(reported.first, 13, "last moved at 9: stalled from 13") XCTAssertEqual(reported.last, 188, "and no longer than three minutes after it last moved") } func testARebuiltScreenStartingFromZeroIsMovingNotStalled() { // 2026-09-30: a screen the sharer rebuilt arrives on a new receiver // whose count starts again. Up to 0.4.4 only a larger count was // movement, so a rebuilt screen that was moving again stayed stalled // here — and the sharer's notice with it — until the new count passed // the old. var watch = ScreenReceiveWatch() for t in 0..<10 { _ = watch.observe(at: Double(t), decoded: ["paul": 900 + UInt64(t * 30)]) } for t in 10..<16 { _ = watch.observe(at: Double(t), decoded: ["paul": 1170]) } XCTAssertEqual(watch.observe(at: 16, decoded: ["paul": 1170]), ["paul"]) for t in 17..<40 { XCTAssertEqual(watch.observe(at: Double(t), decoded: ["paul": UInt64((t - 17) * 30)]), [], "t=\(t)") } } func testAStalledPictureIsAttachedAgainOnceFourSecondsIn() { var watch = ScreenReceiveWatch() var reattached: [Int] = [] for t in 0..<60 { let decoded: UInt64 = t < 10 ? UInt64(t * 10) : 90 _ = watch.observe(at: Double(t), decoded: ["paul": decoded]) if watch.reattach == ["paul"] { reattached.append(t) } } XCTAssertEqual(reattached, [17], "stalled from 13, attached again four seconds later, once") // Moving again and stalled again: a new stall, a new try. for t in 60..<63 { _ = watch.observe(at: Double(t), decoded: ["paul": UInt64(t)]) } var again: [Int] = [] for t in 63..<80 { _ = watch.observe(at: Double(t), decoded: ["paul": 62]) if !watch.reattach.isEmpty { again.append(t) } } XCTAssertEqual(again, [70]) } func testAMovingScreenIsNeverReported() { var watch = ScreenReceiveWatch() for t in 0..<30 { XCTAssertEqual(watch.observe(at: Double(t), decoded: ["paul": UInt64(t)]), []) } } func testAScreenThatGoesIsForgotten() { var watch = ScreenReceiveWatch() for t in 0..<10 { _ = watch.observe(at: Double(t), decoded: ["paul": 5]) } XCTAssertEqual(watch.observe(at: 10, decoded: ["paul": 5]), ["paul"]) XCTAssertEqual(watch.observe(at: 11, decoded: [:]), []) // Back again, a fresh start. XCTAssertEqual(watch.observe(at: 12, decoded: ["paul": 5]), []) } // MARK: - Words and wires func testTheSharersNotice() { XCTAssertEqual(ScreenWords.recovering(names: []), "") XCTAssertEqual(ScreenWords.recovering(names: ["Vicky"]), "Vicky\u{2019}s view of your screen stalled \u{2014} recovering") XCTAssertEqual( ScreenWords.recovering(names: ["Tony", "Vicky"]), "Tony\u{2019}s and Vicky\u{2019}s views of your screen stalled \u{2014} recovering") XCTAssertEqual(ScreenWords.recovering(names: ["A", "B", "C"]), "3 people\u{2019}s views of your screen stalled \u{2014} recovering") } func testTheStalledListIsReadFromTheRoster() throws { let json = #"{"muted":false,"cameraOff":false,"screenStalled":["0123456789abcdef0123456789abcdef"]}"# let media = try JSONDecoder().decode(PeerMedia.self, from: Data(json.utf8)) XCTAssertEqual(media.screenStalled, ["0123456789abcdef0123456789abcdef"]) let plain = try JSONDecoder().decode(PeerMedia.self, from: Data(#"{"muted":true,"cameraOff":false}"#.utf8)) XCTAssertNil(plain.screenStalled) } func testTheDetailLineSaysWhatTheEncoderAndTheWatchdogAreDoing() { var before = ScreenSendReport() before.timestamp = 10 before.framesEncoded = 100 before.framesSent = 100 var now = before now.timestamp = 12 now.framesEncoded = 160 now.framesSent = 158 now.keyFramesEncoded = 3 now.keyframeRequests = 2 XCTAssertEqual( now.detail(since: before, watchdog: "watching"), "Encoded 30 fps, sent 29 fps, keyframes 3 (2 asked for); watchdog: watching") } // MARK: - ICE renewal (§5.3) private func ice(_ renewAt: Int64, servers: Int = 1) -> ICEConfig { ICEConfig( iceServers: (0..