mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
f7f55024ab
The suite intermittently hung on CI, most recently for the full 2 minute time limit on lifetimeIsCapped(). A spindump from a failed run shows the process suspended rather than spinning (0.247s of CPU over 190s) with a background priority worker thread sitting runnable for roughly 145 seconds without ever being scheduled. That band is exactly what swift-clocks' TestClock depends on to make progress: both advance() and checkSuspension() await Task.megaYield(), which serially awaits 20 detached tasks at background priority. On an oversubscribed runner those never run, so the test waits forever instead of failing. The same mechanism, less starved, is why two of the passing tests took 1.0s and 0.77s for what is otherwise pure logic. checkSuspension() made it worse, since asking whether a timer was armed cost a megaYield per poll. ManualClock replaces it. It holds a single pending sleep, since that is all a test needs to step through: the sleep registers a continuation, advance(by:) resumes it synchronously once its deadline passes, and waitForScheduledSleep() suspends until the timer is armed instead of polling for it. Nothing in the path touches background priority and no loop's iteration count depends on the scheduler. The suite now runs in 0.019s, and under a locally saturated CPU the old version took 1.005s against the new one's 0.037s. Also drops the now unused Clocks package, removes the 2 minute time limit that was papering over the starvation, re-enables callIsTimingOut() which the same flakiness had disabled, and has lifetimeIsCapped() assert the 90 second cap directly rather than inferring it from when the timer fires.
288 lines
12 KiB
Swift
288 lines
12 KiB
Swift
//
|
|
// Copyright 2025 New Vector Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
import CallKit
|
|
@testable import ElementX
|
|
import PushKit
|
|
import Testing
|
|
|
|
@MainActor
|
|
final class ElementCallServiceTests {
|
|
private var callProvider: CXProviderMock!
|
|
private var currentDate: Date!
|
|
private var clock: ManualClock!
|
|
private var pushRegistry: PKPushRegistry!
|
|
private var service: ElementCallService!
|
|
|
|
init() {
|
|
pushRegistry = PKPushRegistry(queue: nil)
|
|
callProvider = CXProviderMock(.init())
|
|
currentDate = Date()
|
|
clock = ManualClock()
|
|
let dateProvider: () -> Date = {
|
|
self.currentDate
|
|
}
|
|
service = ElementCallService(callProvider: callProvider, timeProvider: TimeProvider(clock: clock, now: dateProvider))
|
|
}
|
|
|
|
isolated deinit {
|
|
callProvider = nil
|
|
currentDate = nil
|
|
clock = nil
|
|
pushRegistry = nil
|
|
}
|
|
|
|
@Test
|
|
func incomingCall() async {
|
|
#expect(!callProvider.reportNewIncomingCallWithUpdateCompletionCalled)
|
|
|
|
await waitForConfirmation { confirmation in
|
|
let pkPushPayloadMock = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 30)
|
|
|
|
service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: pkPushPayloadMock, for: .voIP) {
|
|
confirmation()
|
|
}
|
|
}
|
|
|
|
#expect(callProvider.reportNewIncomingCallWithUpdateCompletionCalled)
|
|
// Verify the provider was called with a CXCallUpdate that has video enabled
|
|
if let args = callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments {
|
|
#expect(args.update.hasVideo == true)
|
|
} else {
|
|
Issue.record("Expected reportNewIncomingCallWithUpdateCompletionReceivedArguments to be captured")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func incomingVoiceCall() async {
|
|
#expect(!callProvider.reportNewIncomingCallWithUpdateCompletionCalled)
|
|
|
|
await waitForConfirmation { confirmation in
|
|
let pkPushPayloadMock = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 30)
|
|
.updateIsVoice(true)
|
|
|
|
service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: pkPushPayloadMock, for: .voIP) {
|
|
confirmation()
|
|
}
|
|
}
|
|
|
|
#expect(callProvider.reportNewIncomingCallWithUpdateCompletionCalled)
|
|
// Verify the provider was called with a CXCallUpdate that has video enabled
|
|
if let args = callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments {
|
|
// Due to a limitation on Callkit and Webviews, we currently have to report voice calls as having video,
|
|
// even if they are voice calls :/ If not the webview is not started and the call is not shown to the user.
|
|
#expect(args.update.hasVideo == true)
|
|
} else {
|
|
Issue.record("Expected reportNewIncomingCallWithUpdateCompletionReceivedArguments to be captured")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func callIsTimingOut() async throws {
|
|
#expect(!callProvider.reportNewIncomingCallWithUpdateCompletionCalled)
|
|
|
|
let (endedCalls, endedCallsContinuation) = AsyncStream<CXCallEndedReason>.makeStream()
|
|
callProvider.reportCallWithEndedAtReasonClosure = { _, _, reason in
|
|
endedCallsContinuation.yield(reason)
|
|
}
|
|
let deferredEndedCall = deferFulfillment(endedCalls) { _ in true }
|
|
|
|
await waitForConfirmation { confirmation in
|
|
let pushPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 20)
|
|
|
|
service.pushRegistry(pushRegistry,
|
|
didReceiveIncomingPushWith: pushPayload,
|
|
for: .voIP) {
|
|
confirmation()
|
|
}
|
|
}
|
|
|
|
await clock.waitForScheduledSleep()
|
|
|
|
// advance past the timeout
|
|
clock.advance(by: .seconds(30))
|
|
|
|
let reason = try await deferredEndedCall.fulfill()
|
|
#expect(reason == .unanswered, "Call should have ended as unanswered")
|
|
}
|
|
|
|
@Test
|
|
func lifetimeIsCapped() async throws {
|
|
#expect(!callProvider.reportNewIncomingCallWithUpdateCompletionCalled)
|
|
|
|
let (endedCalls, endedCallsContinuation) = AsyncStream<CXCallEndedReason>.makeStream()
|
|
callProvider.reportCallWithEndedAtReasonClosure = { _, _, reason in
|
|
endedCallsContinuation.yield(reason)
|
|
}
|
|
let deferredEndedCall = deferFulfillment(endedCalls, timeout: .seconds(30)) { _ in true }
|
|
|
|
let pushPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 300)
|
|
|
|
service.pushRegistry(pushRegistry,
|
|
didReceiveIncomingPushWith: pushPayload,
|
|
for: .voIP) { }
|
|
|
|
// The ring duration is capped, no matter how far away the push's expiration is.
|
|
#expect(await clock.waitForScheduledSleep() == .seconds(90))
|
|
|
|
// Advance past the max timeout but below the 300
|
|
clock.advance(by: .seconds(100))
|
|
|
|
let reason = try await deferredEndedCall.fulfill()
|
|
#expect(reason == .unanswered, "Call should have ended as unanswered")
|
|
}
|
|
|
|
@Test
|
|
func callIntentRawValues() {
|
|
// Test to ensure that the implicit rawValue of the string enum matches the MSC values
|
|
#expect(CallIntent.audio.rawValue == "audio")
|
|
#expect(CallIntent.video.rawValue == "video")
|
|
}
|
|
|
|
@Test
|
|
func timeoutClearsIncomingCallStateBeforeNextPush() async throws {
|
|
// Drive push #1 to its 60s unanswered timeout
|
|
let (endedCalls, endedCallsContinuation) = AsyncStream<CXCallEndedReason>.makeStream()
|
|
callProvider.reportCallWithEndedAtReasonClosure = { _, _, reason in
|
|
endedCallsContinuation.yield(reason)
|
|
}
|
|
let deferredEndedCall = deferFulfillment(endedCalls, timeout: .seconds(30)) { $0 == .unanswered }
|
|
|
|
let firstPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 60)
|
|
service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: firstPayload, for: .voIP) { }
|
|
|
|
await clock.waitForScheduledSleep()
|
|
clock.advance(by: .seconds(70))
|
|
try await deferredEndedCall.fulfill()
|
|
|
|
callProvider.reportCallWithEndedAtReasonClosure = nil
|
|
let firstCallUUID = callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.uuid
|
|
|
|
// Send push #2 for the same room; the previous incoming state must be cleared,
|
|
// so the second push gets a fresh CallID.
|
|
await waitForConfirmation { confirmation in
|
|
let secondPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 60)
|
|
service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: secondPayload, for: .voIP) {
|
|
confirmation()
|
|
}
|
|
}
|
|
|
|
let secondCallUUID = callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.uuid
|
|
|
|
#expect(firstCallUUID != nil)
|
|
#expect(secondCallUUID != nil)
|
|
#expect(firstCallUUID != secondCallUUID)
|
|
|
|
let unansweredCount = callProvider.reportCallWithEndedAtReasonReceivedInvocations.filter { $0.reason == .unanswered }.count
|
|
#expect(unansweredCount == 1)
|
|
}
|
|
|
|
@Test
|
|
func setupCallSessionCancelsPendingUnansweredTimeout() async {
|
|
// Schedule the 60s unanswered timer via an incoming push
|
|
await waitForConfirmation { confirmation in
|
|
let payload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 60)
|
|
service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: payload, for: .voIP) {
|
|
confirmation()
|
|
}
|
|
}
|
|
|
|
// Make sure the cancellation below exercises a scheduled timer rather than one that never started.
|
|
await clock.waitForScheduledSleep()
|
|
|
|
// Simulate the answer flow handing off to setupCallSession, which must cancel
|
|
// the pending endUnansweredCallTask as part of clearing the incoming state.
|
|
await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome")
|
|
|
|
var unansweredFired = false
|
|
callProvider.reportCallWithEndedAtReasonClosure = { _, _, reason in
|
|
if reason == .unanswered {
|
|
unansweredFired = true
|
|
}
|
|
}
|
|
|
|
// Advance past what would have been the 60s unanswered timeout
|
|
clock.advance(by: .seconds(120))
|
|
for _ in 0..<3 {
|
|
await Task.yield()
|
|
}
|
|
|
|
#expect(!unansweredFired, "endUnansweredCallTask should have been cancelled by setupCallSession")
|
|
}
|
|
|
|
@Test
|
|
func expiredPushReportsMissedCall() async {
|
|
// An expired push is a real call we missed, so it should show up in Recents as one.
|
|
let pushPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 20)
|
|
currentDate = currentDate.addingTimeInterval(60)
|
|
await expectImmediatelyEndedCallReported(forPayload: pushPayload, expectedReason: .unanswered)
|
|
|
|
let update = callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.update
|
|
#expect(update?.localizedCallerName == "welcome")
|
|
#expect(update?.remoteHandle?.value == "!room:example.com")
|
|
}
|
|
|
|
@Test
|
|
func duplicateRoomPushReportsCallAsHandled() async {
|
|
// A duplicate push for an ongoing call is reported as handled, leaving the ongoing call alone.
|
|
await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome")
|
|
let pushPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 30)
|
|
await expectImmediatelyEndedCallReported(forPayload: pushPayload, expectedReason: .answeredElsewhere)
|
|
|
|
// The call should be named so neither the brief system UI nor the Recents entry shows "Unknown".
|
|
let update = callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.update
|
|
#expect(update?.localizedCallerName == "welcome")
|
|
|
|
#expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com")
|
|
}
|
|
|
|
private func expectImmediatelyEndedCallReported(forPayload payload: PKPushPayloadMock,
|
|
expectedReason: CXCallEndedReason) async {
|
|
let baselineNewIncomingCount = callProvider.reportNewIncomingCallWithUpdateCompletionCallsCount
|
|
let baselineEndedCount = callProvider.reportCallWithEndedAtReasonCallsCount
|
|
|
|
await waitForConfirmation { confirmation in
|
|
service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: payload, for: .voIP) {
|
|
confirmation()
|
|
}
|
|
}
|
|
|
|
#expect(callProvider.reportNewIncomingCallWithUpdateCompletionCallsCount == baselineNewIncomingCount + 1)
|
|
#expect(callProvider.reportCallWithEndedAtReasonCallsCount == baselineEndedCount + 1)
|
|
|
|
let reportedCall = callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments
|
|
let endedCall = callProvider.reportCallWithEndedAtReasonReceivedArguments
|
|
#expect(reportedCall?.uuid == endedCall?.uuid)
|
|
#expect(endedCall?.reason == expectedReason)
|
|
}
|
|
}
|
|
|
|
private nonisolated class PKPushPayloadMock: PKPushPayload {
|
|
var dict: [AnyHashable: Any] = [:]
|
|
|
|
override init() {
|
|
dict[ElementCallServiceNotificationKey.roomID.rawValue] = "!room:example.com"
|
|
dict[ElementCallServiceNotificationKey.roomDisplayName.rawValue] = "welcome"
|
|
dict[ElementCallServiceNotificationKey.rtcNotifyEventID.rawValue] = "$000"
|
|
dict[ElementCallServiceNotificationKey.expirationDate.rawValue] = Date(timeIntervalSince1970: 10)
|
|
}
|
|
|
|
override var dictionaryPayload: [AnyHashable: Any] {
|
|
dict
|
|
}
|
|
|
|
func updatingExpiration(_ from: Date, lifetime: TimeInterval) -> Self {
|
|
dict[ElementCallServiceNotificationKey.expirationDate.rawValue] = from.addingTimeInterval(lifetime)
|
|
return self
|
|
}
|
|
|
|
func updateIsVoice(_ isVoice: Bool) -> Self {
|
|
dict[ElementCallServiceNotificationKey.isVoiceCall.rawValue] = isVoice
|
|
return self
|
|
}
|
|
}
|