From 5dcc38201a32e0bd1000962dda5c8829c0d8452a Mon Sep 17 00:00:00 2001 From: Gustl22 Date: Wed, 5 Oct 2022 17:59:42 +0200 Subject: [PATCH] test: player state, improved position & duration (#1257, #1298) (#1284) * test: player state tests (#1257) * test: more reliable duration checks * test: test for duration on live stream * test: improve durationRangeMatcher * test(example): allow cleartext traffic / AllowsArbitraryLoads on darwin * test: test onPosition on every platform * tests: overwrite setState for streams tab * fix(linux): install gstreamer1.0-plugins-bad for m3u8 tests * test(windows): disable playlist source (m3u8) on windows --- .github/workflows/build.yaml | 258 +++++++++--------- feature_parity_table.md | 4 +- .../example/integration_test/app_test.dart | 4 +- .../integration_test/platform_features.dart | 21 +- .../integration_test/source_test_data.dart | 6 +- .../integration_test/tabs/controls_tab.dart | 21 +- .../integration_test/tabs/source_tab.dart | 4 +- .../integration_test/tabs/stream_tab.dart | 209 ++++++++++---- .../example/integration_test/test_utils.dart | 59 +++- .../example/ios/Runner/Info.plist | 9 +- .../example/lib/tabs/streams.dart | 9 + .../example/macos/Runner/Info.plist | 5 + 12 files changed, 379 insertions(+), 230 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 10d2ce5bd..d2a6bdde7 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -1,6 +1,6 @@ name: build on: - workflow_dispatch: {} + workflow_dispatch: { } push: branches: - main @@ -34,160 +34,160 @@ jobs: timeout-minutes: 30 if: github.event.pull_request.draft == false steps: - - uses: actions/checkout@v3 - - - uses: nanasess/setup-chromedriver@v1 + - uses: actions/checkout@v3 - - uses: subosito/flutter-action@v2 - with: - channel: stable - - uses: bluefireteam/melos-action@main + - uses: nanasess/setup-chromedriver@v1 - - name: Example app - Build Web app - working-directory: ./packages/audioplayers/example - run: | - flutter build web + - uses: subosito/flutter-action@v2 + with: + channel: stable + - uses: bluefireteam/melos-action@main - - name: Run Flutter integration tests - working-directory: ./packages/audioplayers/example - run: | - export DISPLAY=:99 - chromedriver --port=4444 & - sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & # optional + - name: Example app - Build Web app + working-directory: ./packages/audioplayers/example + run: | + flutter build web - flutter drive \ - --driver=test_driver/integration_test.dart \ - --target=integration_test/app_test.dart \ - -d web-server --web-browser-flag="--autoplay-policy=no-user-gesture-required" + - name: Run Flutter integration tests + working-directory: ./packages/audioplayers/example + run: | + export DISPLAY=:99 + chromedriver --port=4444 & + sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & # optional + + flutter drive \ + --driver=test_driver/integration_test.dart \ + --target=integration_test/app_test.dart \ + -d web-server --web-browser-flag="--autoplay-policy=no-user-gesture-required" android: runs-on: macOS-latest timeout-minutes: 60 if: github.event.pull_request.draft == false steps: - - uses: actions/checkout@v3 - - - name: Setup Java - uses: actions/setup-java@v2 - with: - distribution: 'temurin' - java-version: '17' - - - name: Setup Android SDK - uses: android-actions/setup-android@v2 - - - uses: subosito/flutter-action@v2 - with: - channel: stable - - uses: bluefireteam/melos-action@main - - - name: Example App - Build Android APK - working-directory: ./packages/audioplayers/example - run: | - flutter build apk --release - - - name: Run Android unit tests - working-directory: ./packages/audioplayers/example/android - run: | - ./gradlew test - - - name: Download Android emulator image - run: | - export ANDROID_TOOLS="$ANDROID_HOME/cmdline-tools/latest/bin" - echo "y" | $ANDROID_TOOLS/sdkmanager --install "system-images;android-30;aosp_atd;x86" - echo "no" | $ANDROID_TOOLS/avdmanager create avd --force --name emu --device "Nexus 5X" -k 'system-images;android-30;aosp_atd;x86' - $ANDROID_HOME/emulator/emulator -list-avds - - name: Start Android emulator - timeout-minutes: 10 - run: | - export ANDROID_TOOLS="$ANDROID_HOME/cmdline-tools/latest/bin" - echo "Starting emulator" - $ANDROID_TOOLS/sdkmanager "platform-tools" "platforms;android-30" - nohup $ANDROID_HOME/emulator/emulator -avd emu -no-audio -no-snapshot -no-window & - $ANDROID_HOME/platform-tools/adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed | tr -d '\r') ]]; do sleep 1; done; input keyevent 82' - $ANDROID_HOME/platform-tools/adb devices - echo "Emulator started" - - name: Run Flutter integration tests - working-directory: ./packages/audioplayers/example - run: "flutter test integration_test" + - uses: actions/checkout@v3 + + - name: Setup Java + uses: actions/setup-java@v2 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v2 + + - uses: subosito/flutter-action@v2 + with: + channel: stable + - uses: bluefireteam/melos-action@main + + - name: Example App - Build Android APK + working-directory: ./packages/audioplayers/example + run: | + flutter build apk --release + + - name: Run Android unit tests + working-directory: ./packages/audioplayers/example/android + run: | + ./gradlew test + + - name: Download Android emulator image + run: | + export ANDROID_TOOLS="$ANDROID_HOME/cmdline-tools/latest/bin" + echo "y" | $ANDROID_TOOLS/sdkmanager --install "system-images;android-30;aosp_atd;x86" + echo "no" | $ANDROID_TOOLS/avdmanager create avd --force --name emu --device "Nexus 5X" -k 'system-images;android-30;aosp_atd;x86' + $ANDROID_HOME/emulator/emulator -list-avds + - name: Start Android emulator + timeout-minutes: 10 + run: | + export ANDROID_TOOLS="$ANDROID_HOME/cmdline-tools/latest/bin" + echo "Starting emulator" + $ANDROID_TOOLS/sdkmanager "platform-tools" "platforms;android-30" + nohup $ANDROID_HOME/emulator/emulator -avd emu -no-audio -no-snapshot -no-window & + $ANDROID_HOME/platform-tools/adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed | tr -d '\r') ]]; do sleep 1; done; input keyevent 82' + $ANDROID_HOME/platform-tools/adb devices + echo "Emulator started" + - name: Run Flutter integration tests + working-directory: ./packages/audioplayers/example + run: "flutter test integration_test" ios: runs-on: macOS-latest timeout-minutes: 60 if: github.event.pull_request.draft == false steps: - - uses: actions/checkout@v3 - - uses: subosito/flutter-action@v2 - with: - channel: stable - - uses: bluefireteam/melos-action@main - - - name: List all simulators - run: "xcrun simctl list devices" - - name: Start simulator - run: | - UDID=$(xcrun simctl list devices | grep "iPhone" | sed -n 1p | awk -F '\\)? \\(' '{ print $2 }') - echo "Using simulator $UDID" - xcrun simctl boot "${UDID:?No Simulator with this name iPhone found}" - - - name: Example app - Build iOS - working-directory: ./packages/audioplayers/example - run: | - flutter build ios --release --no-codesign - - name: Run Flutter integration tests - working-directory: ./packages/audioplayers/example - run: "flutter test integration_test" + - uses: actions/checkout@v3 + - uses: subosito/flutter-action@v2 + with: + channel: stable + - uses: bluefireteam/melos-action@main + + - name: List all simulators + run: "xcrun simctl list devices" + - name: Start simulator + run: | + UDID=$(xcrun simctl list devices | grep "iPhone" | sed -n 1p | awk -F '\\)? \\(' '{ print $2 }') + echo "Using simulator $UDID" + xcrun simctl boot "${UDID:?No Simulator with this name iPhone found}" + + - name: Example app - Build iOS + working-directory: ./packages/audioplayers/example + run: | + flutter build ios --release --no-codesign + - name: Run Flutter integration tests + working-directory: ./packages/audioplayers/example + run: "flutter test integration_test" macos: runs-on: macOS-latest timeout-minutes: 30 if: github.event.pull_request.draft == false steps: - - uses: actions/checkout@v3 - - uses: subosito/flutter-action@v2 - with: - channel: stable - - uses: bluefireteam/melos-action@main - - - name: setup-cocoapods - uses: maxim-lobanov/setup-cocoapods@v1 - with: - podfile-path: ./packages/audioplayers/example/macos/Podfile.lock - - name: Example app - Build macOS - working-directory: ./packages/audioplayers/example - run: | - flutter config --enable-macos-desktop - flutter build macos --release - - name: Run Flutter integration tests - working-directory: ./packages/audioplayers/example - run: "flutter test -d macos integration_test" + - uses: actions/checkout@v3 + - uses: subosito/flutter-action@v2 + with: + channel: stable + - uses: bluefireteam/melos-action@main + + - name: setup-cocoapods + uses: maxim-lobanov/setup-cocoapods@v1 + with: + podfile-path: ./packages/audioplayers/example/macos/Podfile.lock + - name: Example app - Build macOS + working-directory: ./packages/audioplayers/example + run: | + flutter config --enable-macos-desktop + flutter build macos --release + - name: Run Flutter integration tests + working-directory: ./packages/audioplayers/example + run: "flutter test -d macos integration_test" windows: runs-on: windows-latest timeout-minutes: 30 if: github.event.pull_request.draft == false steps: - - uses: actions/checkout@v3 - - uses: subosito/flutter-action@v2 - with: - channel: stable - - uses: bluefireteam/melos-action@main - - name: Example app - Build Windows app - working-directory: ./packages/audioplayers/example - run: | - flutter build windows --release - - name: Start audio server - run: net start audiosrv - - name: Install virtual audio device - shell: powershell - run: | - Invoke-WebRequest https://github.com/duncanthrax/scream/releases/download/3.9/Scream3.9.zip -OutFile Scream3.9.zip - Expand-Archive -Path Scream3.9.zip -DestinationPath Scream - Import-Certificate -FilePath Scream\Install\driver\x64\Scream.cat -CertStoreLocation Cert:\LocalMachine\TrustedPublisher - Scream\Install\helpers\devcon-x64.exe install Scream\Install\driver\x64\Scream.inf *Scream - - name: Run Flutter integration tests - working-directory: ./packages/audioplayers/example - run: "flutter test -d windows integration_test" + - uses: actions/checkout@v3 + - uses: subosito/flutter-action@v2 + with: + channel: stable + - uses: bluefireteam/melos-action@main + - name: Example app - Build Windows app + working-directory: ./packages/audioplayers/example + run: | + flutter build windows --release + - name: Start audio server + run: net start audiosrv + - name: Install virtual audio device + shell: powershell + run: | + Invoke-WebRequest https://github.com/duncanthrax/scream/releases/download/3.9/Scream3.9.zip -OutFile Scream3.9.zip + Expand-Archive -Path Scream3.9.zip -DestinationPath Scream + Import-Certificate -FilePath Scream\Install\driver\x64\Scream.cat -CertStoreLocation Cert:\LocalMachine\TrustedPublisher + Scream\Install\helpers\devcon-x64.exe install Scream\Install\driver\x64\Scream.inf *Scream + - name: Run Flutter integration tests + working-directory: ./packages/audioplayers/example + run: "flutter test -d windows integration_test" linux: runs-on: ubuntu-latest @@ -200,11 +200,11 @@ jobs: channel: stable - uses: bluefireteam/melos-action@main - name: Install Flutter requirements for Linux - run: | + run: | sudo apt-get update sudo apt-get install clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev - name: Install GStreamer - run: sudo apt-get install libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev gstreamer1.0-plugins-good + run: sudo apt-get install libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev gstreamer1.0-plugins-good gstreamer1.0-plugins-bad - name: Example app - Build Linux app working-directory: ./packages/audioplayers/example run: | diff --git a/feature_parity_table.md b/feature_parity_table.md index d1d663fee..c74213e40 100644 --- a/feature_parity_table.md +++ b/feature_parity_table.md @@ -44,7 +44,7 @@ Note: LLM means Low Latency Mode. byte arraySDK >=23not yetnot yetnot yetnot yetnot yet Audio Config set urlyesyesyesyesyesyes - audio cache (pre-load)yesyesyesyesyesyes (?) + audio cache (pre-load)yesyesyesyesyesyes low latency modeSDK >=21nonononono Audio Control Commands resume / pause / stopyesyesyesyesyesyes @@ -63,7 +63,7 @@ Note: LLM means Low Latency Mode. Streams duration eventyesyesyesyesyesyes position eventyesyesyesyesyesyes - state eventyesyesyesyesyesyes (?) + state eventyesyesyesyesyesyes completion eventyesyesyesyesyesyes error eventyesyesyesnot yetyesyes diff --git a/packages/audioplayers/example/integration_test/app_test.dart b/packages/audioplayers/example/integration_test/app_test.dart index 835e4cce1..f37250d70 100644 --- a/packages/audioplayers/example/integration_test/app_test.dart +++ b/packages/audioplayers/example/integration_test/app_test.dart @@ -52,13 +52,13 @@ void main() { SourceTestData( sourceKey: 'url-remote-m3u8', duration: Duration.zero, - isStream: true, + isLiveStream: true, ), if (features.hasUrlSource) SourceTestData( sourceKey: 'url-remote-mpga', duration: Duration.zero, - isStream: true, + isLiveStream: true, ), if (features.hasAssetSource) SourceTestData( diff --git a/packages/audioplayers/example/integration_test/platform_features.dart b/packages/audioplayers/example/integration_test/platform_features.dart index 3ce8e131f..f9cdb717c 100644 --- a/packages/audioplayers/example/integration_test/platform_features.dart +++ b/packages/audioplayers/example/integration_test/platform_features.dart @@ -59,6 +59,7 @@ class PlatformFeatures { static const windowsPlatformFeatures = PlatformFeatures( hasBytesSource: false, + hasPlaylistSourceType: false, hasLowLatency: false, hasDuckAudio: false, hasRespectSilence: false, @@ -73,15 +74,15 @@ class PlatformFeatures { final bool hasPlaylistSourceType; - final bool hasLowLatency; // Not yet tested - final bool hasReleaseModeRelease; // Not yet tested - final bool hasReleaseModeLoop; // Not yet tested - final bool hasVolume; // Not yet tested - final bool hasBalance; // Not yet tested - final bool hasSeek; // Not yet tested - final bool hasMp3Duration; // Not yet tested + final bool hasLowLatency; + final bool hasReleaseModeRelease; + final bool hasReleaseModeLoop; + final bool hasVolume; + final bool hasBalance; + final bool hasSeek; + final bool hasMp3Duration; - final bool hasPlaybackRate; // Not yet tested + final bool hasPlaybackRate; final bool hasDuckAudio; // Not yet tested final bool hasRespectSilence; // Not yet tested final bool hasStayAwake; // Not yet tested @@ -90,7 +91,7 @@ class PlatformFeatures { final bool hasDurationEvent; final bool hasPositionEvent; - final bool hasCompletionEvent; // Not yet tested + final bool hasPlayerStateEvent; final bool hasErrorEvent; // Not yet tested const PlatformFeatures({ @@ -113,7 +114,7 @@ class PlatformFeatures { this.hasPlayingRoute = true, this.hasDurationEvent = true, this.hasPositionEvent = true, - this.hasCompletionEvent = true, + this.hasPlayerStateEvent = true, this.hasErrorEvent = true, }); diff --git a/packages/audioplayers/example/integration_test/source_test_data.dart b/packages/audioplayers/example/integration_test/source_test_data.dart index 61558ef92..f07acb6e6 100644 --- a/packages/audioplayers/example/integration_test/source_test_data.dart +++ b/packages/audioplayers/example/integration_test/source_test_data.dart @@ -4,12 +4,12 @@ class SourceTestData { Duration duration; - bool isStream; + bool isLiveStream; SourceTestData({ required this.sourceKey, required this.duration, - this.isStream = false, + this.isLiveStream = false, }); @override @@ -17,7 +17,7 @@ class SourceTestData { return 'SourceTestData(' 'sourceKey: $sourceKey, ' 'duration: $duration, ' - 'isStream: $isStream' + 'isLiveStream: $isLiveStream' ')'; } } diff --git a/packages/audioplayers/example/integration_test/tabs/controls_tab.dart b/packages/audioplayers/example/integration_test/tabs/controls_tab.dart index 3c59e69ce..d3ea0ee43 100644 --- a/packages/audioplayers/example/integration_test/tabs/controls_tab.dart +++ b/packages/audioplayers/example/integration_test/tabs/controls_tab.dart @@ -30,14 +30,14 @@ Future testControlsTab( await tester.testBalance('0.0'); } - if (features.hasPlaybackRate && !audioSourceTestData.isStream) { + if (features.hasPlaybackRate && !audioSourceTestData.isLiveStream) { // TODO(Gustl22): also test for playback rate in streams await tester.testRate('0.5'); await tester.testRate('2.0'); await tester.testRate('1.0'); } - if (features.hasSeek && !audioSourceTestData.isStream) { + if (features.hasSeek && !audioSourceTestData.isLiveStream) { // TODO(Gustl22): also test seeking in streams final isImmediateDurationSupported = features.hasMp3Duration || !audioSourceTestData.sourceKey.contains('mp3'); @@ -49,9 +49,8 @@ Future testControlsTab( if (isImmediateDurationSupported) { await tester.testPosition( - Duration(milliseconds: audioSourceTestData.duration.inMilliseconds ~/ 2) - .toString() - .substring(0, 8), + Duration(seconds: audioSourceTestData.duration.inSeconds ~/ 2), + matcher: greaterThanOrEqualTo, ); } await tester.tap(find.byKey(const Key('controlsTab'))); @@ -66,7 +65,7 @@ Future testControlsTab( final isBytesSource = audioSourceTestData.sourceKey.contains('bytes'); if (features.hasLowLatency && - !audioSourceTestData.isStream && + !audioSourceTestData.isLiveStream && !isBytesSource) { await tester.testPlayerMode(PlayerMode.lowLatency); @@ -97,7 +96,7 @@ Future testControlsTab( } if (audioSourceTestData.duration < const Duration(seconds: 2) && - !audioSourceTestData.isStream) { + !audioSourceTestData.isLiveStream) { if (features.hasReleaseModeLoop) { await tester.testReleaseMode(ReleaseMode.loop); await tester.pump(const Duration(seconds: 3)); @@ -175,13 +174,13 @@ extension ControlsWidgetTester on WidgetTester { // Wait until appearance and disappearance await waitFor( - () => expect( + () async => expect( find.byKey(const Key('toast-seek-complete-0')), findsOneWidget, ), ); await waitFor( - () => expect( + () async => expect( find.byKey(const Key('toast-seek-complete-0')), findsNothing, ), @@ -196,7 +195,7 @@ extension ControlsWidgetTester on WidgetTester { printOnFailure('Test Player Mode: ${mode.name}'); await tap(find.byKey(Key('control-player-mode-${mode.name}'))); await waitFor( - () => expectEnumToggleHasSelected( + () async => expectEnumToggleHasSelected( const Key('control-player-mode'), matcher: equals(mode), ), @@ -207,7 +206,7 @@ extension ControlsWidgetTester on WidgetTester { printOnFailure('Test Release Mode: ${mode.name}'); await tap(find.byKey(Key('control-release-mode-${mode.name}'))); await waitFor( - () => expectEnumToggleHasSelected( + () async => expectEnumToggleHasSelected( const Key('control-release-mode'), matcher: equals(mode), ), diff --git a/packages/audioplayers/example/integration_test/tabs/source_tab.dart b/packages/audioplayers/example/integration_test/tabs/source_tab.dart index c809abec7..511d2d84c 100644 --- a/packages/audioplayers/example/integration_test/tabs/source_tab.dart +++ b/packages/audioplayers/example/integration_test/tabs/source_tab.dart @@ -27,7 +27,7 @@ extension ControlsWidgetTester on WidgetTester { // Wait for toast appearance and disappearance await waitFor( - () => expect( + () async => expect( find.byKey(const Key('toast-source-set')), findsOneWidget, ), @@ -35,7 +35,7 @@ extension ControlsWidgetTester on WidgetTester { stackTrace: st, ); await waitFor( - () => expect( + () async => expect( find.byKey(const Key('toast-source-set')), findsNothing, ), diff --git a/packages/audioplayers/example/integration_test/tabs/stream_tab.dart b/packages/audioplayers/example/integration_test/tabs/stream_tab.dart index c66b56ac0..fbf50e418 100644 --- a/packages/audioplayers/example/integration_test/tabs/stream_tab.dart +++ b/packages/audioplayers/example/integration_test/tabs/stream_tab.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -17,112 +18,204 @@ Future testStreamsTab( await tester.pumpAndSettle(); // Stream position is tracked as soon as source is loaded - if (!audioSourceTestData.isStream) { + if (features.hasPositionEvent && !audioSourceTestData.isLiveStream) { // Display position before playing - await tester.testPosition('0:00:00.000000'); + await tester.testPosition(Duration.zero); } final isImmediateDurationSupported = features.hasMp3Duration || !audioSourceTestData.sourceKey.contains('mp3'); - if (!audioSourceTestData.isStream && isImmediateDurationSupported) { + if (features.hasDurationEvent && isImmediateDurationSupported) { // Display duration before playing - await tester.testDuration(audioSourceTestData); + await tester.testDuration(audioSourceTestData.duration); } + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('play_button'))); - await tester.pump(); + await tester.pumpAndSettle(); + + // Cannot test more precisely as it is dependent on pollInterval + // and updateInterval of native implementation. + if (audioSourceTestData.isLiveStream || + audioSourceTestData.duration > const Duration(seconds: 2)) { + // Test player state: playing + if (features.hasPlayerStateEvent) { + // Only test, if there's enough time to be able to check playing state. + await tester.testPlayerState(PlayerState.playing); + await tester.testOnPlayerState(PlayerState.playing); + } - if (!audioSourceTestData.isStream) { // Test if onPositionText is set. - // Cannot test more precisely as it is dependent on pollInterval. - // TODO(Gustl22): test position update in seek mode. if (features.hasPositionEvent) { - // TODO(Gustl22): avoid flaky onPosition test for Android only. - // Reason is, that some frames are skipped on CI and position is not - // updated in time. Once one can reproduce it reliably, we can fix - // and enable it again. - if (kIsWeb || !Platform.isAndroid) { - await tester.testOnPosition('0:00:00'); - } + await tester.testPosition(Duration.zero, matcher: greaterThan); + await tester.testOnPosition(Duration.zero, matcher: greaterThan); } } - if (!audioSourceTestData.isStream && isImmediateDurationSupported) { + if (features.hasDurationEvent && !audioSourceTestData.isLiveStream) { // Test if onDurationText is set. - if (features.hasDurationEvent) { - await tester.testOnDuration(audioSourceTestData); - } + await tester.testOnDuration(audioSourceTestData.duration); } - const sampleDuration = Duration(seconds: 2); + const sampleDuration = Duration(seconds: 3); await tester.pump(sampleDuration); - // Display duration after end / stop (some samples are shorter than sampleDuration, so this test would fail) - // TODO(Gustl22): Not possible at the moment (shows duration of 0) - // await testDuration(); - // await testOnDuration(); + // Test player states: pause, stop, completed + if (features.hasPlayerStateEvent) { + if (!audioSourceTestData.isLiveStream) { + if (audioSourceTestData.duration < const Duration(seconds: 2)) { + await tester.testPlayerState(PlayerState.completed); + await tester.testOnPlayerState(PlayerState.completed); + } else if (audioSourceTestData.duration > const Duration(seconds: 5)) { + await tester.tap(find.byKey(const Key('pause_button'))); + await tester.testPlayerState(PlayerState.paused); + await tester.testOnPlayerState(PlayerState.paused); + + await tester.tap(find.byKey(const Key('stop_button'))); + await tester.testPlayerState(PlayerState.stopped); + await tester.testOnPlayerState(PlayerState.stopped); + } else { + // Cannot say for sure, if it's stopped or completed, so we just stop + await tester.tap(find.byKey(const Key('stop_button'))); + } + } else { + await tester.tap(find.byKey(const Key('stop_button'))); + await tester.testPlayerState(PlayerState.stopped); + await tester.testOnPlayerState(PlayerState.stopped); + } + } - await tester.tap(find.byKey(const Key('pause_button'))); - await tester.tap(find.byKey(const Key('stop_button'))); + // Display duration & position after completion / stop + // FIXME(Gustl22): Linux does not support duration after completion event + if (features.hasDurationEvent && (kIsWeb || !Platform.isLinux)) { + await tester.testDuration(audioSourceTestData.duration); + if (!audioSourceTestData.isLiveStream) { + await tester.testOnDuration(audioSourceTestData.duration); + } + } + if (features.hasPositionEvent && !audioSourceTestData.isLiveStream) { + await tester.testPosition(Duration.zero); + } } extension StreamWidgetTester on WidgetTester { - Future testDuration(SourceTestData sourceTestData) async { - final durationStr = sourceTestData.duration.toString().substring(0, 8); - printOnFailure('Test Duration: $durationStr'); + // Precision for duration & position: + // Android: two tenth of a second + // Windows: second + // Linux: second + // Web: second + + // Update interval for duration & position: + // Android: two tenth of a second + // Windows: second + // Linux: second + // Web: second + + bool _durationRangeMatcher( + Duration? actual, + Duration? expected, { + Duration deviation = const Duration(seconds: 1), + }) { + if (actual == null && expected == null) { + return true; + } + if (actual == null || expected == null) { + return false; + } + return actual >= (expected - deviation) && actual <= (expected + deviation); + } + + Future testDuration(Duration duration) async { + printOnFailure('Test Duration: $duration'); final st = StackTrace.current.toString(); - await tap(find.byKey(const Key('getDuration'))); await waitFor( - () => expectWidgetHasText( - const Key('durationText'), - // Precision for duration: - // Android: two tenth of a second - // Windows: second - // Linux: second - matcher: contains(durationStr), - ), - timeout: const Duration(seconds: 2), + () async { + await tap(find.byKey(const Key('getDuration'))); + await pump(); + expectWidgetHasDuration( + const Key('durationText'), + matcher: (Duration? actual) => + _durationRangeMatcher(actual, duration), + ); + }, + timeout: const Duration(seconds: 4), stackTrace: st, ); } - Future testPosition(String positionStr) async { - printOnFailure('Test Position: $positionStr'); + Future testPosition( + Duration position, { + Matcher Function(Duration) matcher = equals, + }) async { + printOnFailure('Test Position: $position'); final st = StackTrace.current.toString(); - await tap(find.byKey(const Key('getPosition'))); await waitFor( - () => expectWidgetHasText( - const Key('positionText'), - matcher: contains(positionStr), - ), - timeout: const Duration(seconds: 2), + () async { + await tap(find.byKey(const Key('getPosition'))); + await pump(); + expectWidgetHasDuration( + const Key('positionText'), + matcher: matcher(position), + ); + }, + timeout: const Duration(seconds: 4), + stackTrace: st, + ); + } + + Future testPlayerState(PlayerState playerState) async { + printOnFailure('Test PlayerState: $playerState'); + final st = StackTrace.current.toString(); + await waitFor( + () async { + await tap(find.byKey(const Key('getPlayerState'))); + await pump(); + expectWidgetHasText( + const Key('playerStateText'), + matcher: contains(playerState.toString()), + ); + }, + timeout: const Duration(seconds: 4), stackTrace: st, ); } - Future testOnDuration(SourceTestData sourceTestData) async { - final durationStr = sourceTestData.duration.toString().substring(0, 8); - printOnFailure('Test OnDuration: $durationStr'); + Future testOnDuration(Duration duration) async { + printOnFailure('Test OnDuration: $duration'); final st = StackTrace.current.toString(); await waitFor( - () => expectWidgetHasText( + () async => expectWidgetHasDuration( const Key('onDurationText'), - matcher: contains( - 'Stream Duration: $durationStr', - ), + matcher: (Duration? actual) => _durationRangeMatcher(actual, duration), ), stackTrace: st, ); } - Future testOnPosition(String positionStr) async { - printOnFailure('Test OnPosition: $positionStr'); + Future testOnPosition( + Duration position, { + Matcher Function(Duration) matcher = equals, + }) async { + printOnFailure('Test OnPosition: $position'); final st = StackTrace.current.toString(); await waitFor( - () => expectWidgetHasText( + () async => expectWidgetHasDuration( const Key('onPositionText'), - matcher: contains('Stream Position: $positionStr'), + matcher: matcher(position), + ), + pollInterval: const Duration(milliseconds: 250), + stackTrace: st, + ); + } + + Future testOnPlayerState(PlayerState playerState) async { + printOnFailure('Test OnState: $playerState'); + final st = StackTrace.current.toString(); + await waitFor( + () async => expectWidgetHasText( + const Key('onStateText'), + matcher: contains('Stream State: $playerState'), ), pollInterval: const Duration(milliseconds: 250), stackTrace: st, diff --git a/packages/audioplayers/example/integration_test/test_utils.dart b/packages/audioplayers/example/integration_test/test_utils.dart index 11aa7a4e5..53ee7cdab 100644 --- a/packages/audioplayers/example/integration_test/test_utils.dart +++ b/packages/audioplayers/example/integration_test/test_utils.dart @@ -6,16 +6,16 @@ import 'package:flutter_test/flutter_test.dart'; extension WidgetTesterUtils on WidgetTester { // Add [stackTrace] to work around https://github.com/flutter/flutter/issues/89138 Future waitFor( - void Function() testExpectation, { + Future Function() testExpectation, { Duration? timeout = const Duration(seconds: 15), Duration? pollInterval = const Duration(milliseconds: 500), String? stackTrace, - }) => + }) async => _waitUntil( (setFailureMessage) async { try { await pump(); - testExpectation(); + await testExpectation(); return true; } on TestFailure catch (e) { setFailureMessage(e.message ?? ''); @@ -32,14 +32,22 @@ extension WidgetTesterUtils on WidgetTester { /// condition does not return true with the timeout period. /// Copied from: https://github.com/jonsamwell/flutter_gherkin/blob/02a4af91d7a2512e0a4540b9b1ab13e36d5c6f37/lib/src/flutter/utils/driver_utils.dart#L86 Future _waitUntil( - Future Function(Function(String message) setFailureMessage) + Future Function(void Function(String message) setFailureMessage) condition, { Duration? timeout = const Duration(seconds: 15), Duration? pollInterval = const Duration(milliseconds: 500), String? stackTrace, }) async { var firstFailureMsg = ''; - var lastFailureMsg = ''; + var lastFailureMsg = 'same as first failure'; + void setFailureMessage(String message) { + if (firstFailureMsg.isEmpty) { + firstFailureMsg = message; + } else { + lastFailureMsg = message; + } + } + try { await Future.microtask( () async { @@ -49,12 +57,7 @@ extension WidgetTesterUtils on WidgetTester { var attempts = 0; while (attempts < maxAttempts) { - final result = await condition((String message) { - if (firstFailureMsg.isEmpty) { - firstFailureMsg = message; - } - lastFailureMsg = message; - }); + final result = await condition(setFailureMessage); if (result) { completer.complete(); break; @@ -105,6 +108,40 @@ void expectWidgetHasText( } } +void expectWidgetHasDuration( + Key key, { + required dynamic matcher, + bool skipOffstage = true, +}) { + final widget = + find.byKey(key, skipOffstage: skipOffstage).evaluate().single.widget; + if (widget is Text) { + final regexp = RegExp(r'\d+:\d{2}:\d{2}.\d{6}'); + final match = regexp.firstMatch(widget.data ?? ''); + final duration = _parseDuration(match?.group(0)); + expect(duration, matcher); + } else { + throw 'Widget with key $key is not a Widget of type "Text"'; + } +} + +/// Parse Duration string to Duration +Duration? _parseDuration(String? s) { + if (s == null || s.isEmpty) { + return null; + } + var hours = 0, minutes = 0, micros = 0; + final parts = s.split(':'); + if (parts.length > 2) { + hours = int.parse(parts[parts.length - 3]); + } + if (parts.length > 1) { + minutes = int.parse(parts[parts.length - 2]); + } + micros = (double.parse(parts[parts.length - 1]) * 1000000).round(); + return Duration(hours: hours, minutes: minutes, microseconds: micros); +} + void expectEnumToggleHasSelected( Key key, { required Matcher matcher, diff --git a/packages/audioplayers/example/ios/Runner/Info.plist b/packages/audioplayers/example/ios/Runner/Info.plist index 5eeb16f7a..10d6a2d22 100644 --- a/packages/audioplayers/example/ios/Runner/Info.plist +++ b/packages/audioplayers/example/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,11 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -43,7 +50,5 @@ UIViewControllerBasedStatusBarAppearance - CADisableMinimumFrameDurationOnPhone - diff --git a/packages/audioplayers/example/lib/tabs/streams.dart b/packages/audioplayers/example/lib/tabs/streams.dart index 3d2daf962..3e26382de 100644 --- a/packages/audioplayers/example/lib/tabs/streams.dart +++ b/packages/audioplayers/example/lib/tabs/streams.dart @@ -38,6 +38,15 @@ class _StreamsTabState extends State ]; } + @override + void setState(VoidCallback fn) { + // Subscriptions only can be closed asynchronously, + // therefore events can occur after widget has been disposed. + if (mounted) { + super.setState(fn); + } + } + @override void dispose() { super.dispose(); diff --git a/packages/audioplayers/example/macos/Runner/Info.plist b/packages/audioplayers/example/macos/Runner/Info.plist index 4789daa6a..ac01e2c55 100644 --- a/packages/audioplayers/example/macos/Runner/Info.plist +++ b/packages/audioplayers/example/macos/Runner/Info.plist @@ -26,6 +26,11 @@ $(PRODUCT_COPYRIGHT) NSMainNibFile MainMenu + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSPrincipalClass NSApplication