Skip to content

Conversation

@stuartmorgan-g
Copy link
Collaborator

Converts all of the native unit tests in video_player_avfoundation to Swift:

  • For the UI integration tests, it's just a direct Swift conversion.
  • For the unit tests, I first did a direct conversion to Swift, including ensuring that everything passed in that version, then converted from XCTest to Swift Testing.

To minimize churn, I deliberately didn't address other technical debt in the tests (e.g., testing of player instances by making an entire plugin instance just to make a player). Some changes were necessary though:

  • Added some missing NS_ASSUME_NONNULL_BEGIN annotations to headers, to avoid having to deal with optional values in the Swift version of tests that couldn't ever actually be null.
  • Changed a utility method to take the necessary parts of AVAssetTrack instead of AVAssetTrack. The tests for that worked by subclassing AVAssetTrack even though it has no public initializers, which was extremely sketchy. It worked okay in Obj-C because Obj-C will let you get away with almost anything, but it doesn't compile in Swift.
  • Some tests that used Expectations to watch for KVO on player items extracted from the player internals have instead been changed to watch for player-level observable events. This is because I was having a lot of trouble getting KVO of AVPlayerItem to actually work in Swift, and rather than try to fix that when it wasn't great test design anyway, I just changed to using something that wasn't an internal detail anyway.

Part of flutter/flutter#119105

Pre-Review Checklist

If you need help, consider asking for advice on the #hackers-new channel on Discord.

Note: The Flutter team is currently trialing the use of Gemini Code Assist for GitHub. Comments from the gemini-code-assist bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed.

Footnotes

  1. Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling. 2 3

@stuartmorgan-g
Copy link
Collaborator Author

stuartmorgan-g commented Feb 9, 2026

It may be useful for review to look at the changes before ec4b403 first, to see the more direct conversion, and then review ec4b403 separately to see what I had to change for Swift Testing.

@stuartmorgan-g stuartmorgan-g added the triage-ios Should be looked at in iOS triage label Feb 10, 2026
private(set) nonisolated(unsafe) var lastSeekTime: CMTime = .invalid

override func seek(
to time: CMTime, toleranceBefore: CMTime, toleranceAfter: CMTime,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: separate lines per style guide -> "Line-Wrapping" section.

Filed: flutter/flutter#182175


/// An AVPlayer subclass that records method call parameters for inspection.
// TODO(stuartmorgan): Replace with a protocol like the other classes.
@MainActor class InspectableAVPlayer: AVPlayer {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: final class (here and rest of this file)

@MainActor class InspectableAVPlayer: AVPlayer {
private(set) nonisolated(unsafe) var beforeTolerance: NSNumber?
private(set) nonisolated(unsafe) var afterTolerance: NSNumber?
private(set) nonisolated(unsafe) var lastSeekTime: CMTime = .invalid
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting. AVPlayer is main actor, but seek is explicitly marked as non-isolated, meaning that Apple likely has some internal synchronization to make seek safe to be called from different threads.

We could make these 3 thread-safe too, but since it's testing only and we don't access it from different threads, using nonisolated(unsafe) is fine here

}

class StubEventListener: NSObject, FVPVideoEventListener {
var initializationContinuation: CheckedContinuation<Void, Never>?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having continuation as ivar is non-standard practice, as it's very easy to misuse (e.g. call resume twice). How about:

class StubEventListener... {
  var onInitialized: (() -> Void)?
  ...
}

@Test func fooTest() {
    // ...
    await withCheckedContinuation { continuation in
      listener.onInitialized = { continuation.resume() }
    }
  }

let mp4TestURI = "https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4"
let hlsTestURI = "https://flutter.github.io/assets-for-api-docs/assets/videos/hls/bee.m3u8"
let mp3AudioTestURI = "https://flutter.github.io/assets-for-api-docs/assets/audio/rooster.mp3"
let hlsAudioTestURI =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: private static let

}

// Temporary test adapter until the player implementation is converted to use async.
private func asyncSeekTo(player: FVPVideoPlayer, time: Int) async {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swift auto-generate the async/await API based on completion API. We should already be able to call

let error = await player.seek(to: time)


let listener = StubEventListener()
await withCheckedContinuation { initialized in
listener.initializationContinuation = initialized
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This and other places in this file: if videoPlayerDidInitialize is not called, the test suite would get stuck (rather than marking this test as failure and continuing).

SwiftTesting has a time limit trait: https://developer.apple.com/documentation/testing/trait/timelimit(_:), but it's iOS 16+ only.

Google search sends me to https://www.donnywals.com/implementing-task-timeout-with-swift-concurrency. I cleaned up the code and updated it for Swift 6:

enum TestError: Error {
  case timeout
}

func withTimeout(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> Void) async throws {
  try await withThrowingTaskGroup(of: Void.self) { group in
    group.addTask { try await operation() }
    group.addTask {
      try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
      throw TestError.timeout
    }
    
    defer { group.cancelAll() }
    try await group.next()!
  }
}

Then in the test:

    try await withTimeout(seconds: 5) {
      await withCheckedContinuation { continuation in
        listener.onInitialized {
          continuation.resume()
        }
      }
    }

I verified it working by adding a sleep:

    try await withTimeout(seconds: 5) {
      try await Task.sleep(nanoseconds: 10 * 1_000_000_000)
      // ...
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants