diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 438aa3b8..78c72b35 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -77,7 +77,7 @@ jobs: run: flutter pub get - name: Build debug APK - run: flutter build apk --debug + run: flutter build apk --debug --dart-define=PLEZY_MAESTRO_E2E=true - name: Cache Maestro CLI id: maestro-cache @@ -272,6 +272,7 @@ jobs: name: maestro-android-diagnostics path: | build/maestro + build/maestro-offline build/maestro-real-jellyfin build/maestro-media build/maestro-tv diff --git a/.maestro/config.yaml b/.maestro/config.yaml index 56e2d4c6..35f913e7 100644 --- a/.maestro/config.yaml +++ b/.maestro/config.yaml @@ -12,3 +12,4 @@ executionOrder: - Open empty downloads state - Manage profiles and open settings - Logout returns to authentication + - Download and play a movie from Downloads diff --git a/.maestro/flows/01_auth.yaml b/.maestro/flows/01_auth.yaml index 35226807..7c0b24a1 100644 --- a/.maestro/flows/01_auth.yaml +++ b/.maestro/flows/01_auth.yaml @@ -28,6 +28,16 @@ tags: timeout: 10000 - assertVisible: "(?s)Server URLs.*" - assertVisible: "Find server" -- hideKeyboard -- back +- runFlow: + when: + platform: iOS + commands: + - tapOn: "Back" +- runFlow: + when: + platform: Android + commands: + - hideKeyboard: + optional: true + - back - assertVisible: "Sign in with Plex" diff --git a/.maestro/flows/02_onboarding_home.yaml b/.maestro/flows/02_onboarding_home.yaml index a74d0d13..890f9ea8 100644 --- a/.maestro/flows/02_onboarding_home.yaml +++ b/.maestro/flows/02_onboarding_home.yaml @@ -7,7 +7,7 @@ tags: - runFlow: ../subflows/onboard_jellyfin.yaml - assertVisible: "(?s)^Home.*" - assertVisible: "(?s)^Recently Added.*" -- assertVisible: "(?s).*Zulu Zone 4.*" +- assertVisible: "(?s).*(?:Alpha Archive|Bravo Beacon|Charlie Circuit|Delta Drive|Echo Engine|Foxtrot Frame|Gamma Garden|Hotel Horizon|India Index|Juliet Junction|Kilo Key|Lima Loop|Mike Matrix|November Node|Oscar Orbit|Papa Pipeline|Quebec Queue|Romeo Relay|Sierra Signal|Tango Track|Uniform Update|Victor View|Whiskey Widget|Xray XML|Yankee Yield|Zulu Zone).*" - assertVisible: "(?s)^Libraries.*" - assertVisible: "(?s)^Search.*" - assertVisible: "(?s)^Downloads.*" diff --git a/.maestro/flows/03_library_browse.yaml b/.maestro/flows/03_library_browse.yaml index 580dbd0d..72972c33 100644 --- a/.maestro/flows/03_library_browse.yaml +++ b/.maestro/flows/03_library_browse.yaml @@ -6,17 +6,28 @@ tags: --- - runFlow: ../subflows/onboard_jellyfin.yaml - tapOn: "(?s)^Libraries.*" +- tapOn: "Browse" - extendedWaitUntil: - visible: "Maestro Movies" + visible: "(?s).*Maestro Movies.*" timeout: 15000 - extendedWaitUntil: - visible: "(?s).*Zulu Zone 4$" + visible: "(?s).*Alpha Archive.*" timeout: 15000 -- tapOn: "(?s).*Zulu Zone 4$" +- tapOn: "(?s).*Alpha Archive.*" - extendedWaitUntil: visible: "Overview" timeout: 15000 - assertVisible: "A deterministic title for TV alphabet focus coverage." - assertVisible: "Play" -- back -- assertVisible: "Maestro Movies" +- runFlow: + when: + platform: iOS + commands: + - tapOn: + point: "6%, 9%" +- runFlow: + when: + platform: Android + commands: + - back +- assertVisible: "(?s).*Maestro Movies.*" diff --git a/.maestro/flows/04_search.yaml b/.maestro/flows/04_search.yaml index da7c8ed6..519e3050 100644 --- a/.maestro/flows/04_search.yaml +++ b/.maestro/flows/04_search.yaml @@ -11,11 +11,20 @@ tags: timeout: 10000 - tapOn: "Search movies, shows, music..." - inputText: "Maestro" -- hideKeyboard +- runFlow: + when: + platform: iOS + commands: + - pressKey: ENTER +- runFlow: + when: + platform: Android + commands: + - hideKeyboard - extendedWaitUntil: - visible: "Maestro Movie" + visible: "(?s).*Maestro Movie.*" timeout: 15000 -- tapOn: "Maestro Movie" +- tapOn: "(?s).*Maestro Movie.*" - extendedWaitUntil: visible: "Overview" timeout: 15000 diff --git a/.maestro/flows/05_playback.yaml b/.maestro/flows/05_playback.yaml index 8b82622d..523b5366 100644 --- a/.maestro/flows/05_playback.yaml +++ b/.maestro/flows/05_playback.yaml @@ -6,24 +6,52 @@ tags: --- - runFlow: ../subflows/onboard_jellyfin.yaml - tapOn: "(?s)^Libraries.*" +- tapOn: "Browse" - extendedWaitUntil: - visible: "(?s).*Zulu Zone 4$" + visible: "(?s).*Alpha Archive.*" timeout: 15000 -- tapOn: "(?s).*Zulu Zone 4$" +- tapOn: "(?s).*Alpha Archive.*" - extendedWaitUntil: visible: "Play" timeout: 15000 - tapOn: text: "Play" waitToSettleTimeoutMs: 1000 -- extendedWaitUntil: - visible: "Pause" - timeout: 20000 -- back -- extendedWaitUntil: - notVisible: "Pause" - timeout: 10000 -- back -- extendedWaitUntil: - visible: "Overview" - timeout: 10000 +- runFlow: + when: + platform: iOS + commands: + - extendedWaitUntil: + visible: "Video timeline" + timeout: 20000 + - extendedWaitUntil: + visible: "(?s).*Alpha Archive.*\\n0min\\n0:(?:0[1-9]|[1-5][0-9])\\n-.*" + timeout: 10000 +- runFlow: + when: + platform: Android + commands: + - extendedWaitUntil: + visible: "Pause" + timeout: 20000 +- runFlow: + when: + platform: iOS + commands: + - tapOn: + point: "90%, 5%" + - extendedWaitUntil: + visible: "Overview" + timeout: 10000 +- runFlow: + when: + platform: Android + commands: + - back + - extendedWaitUntil: + notVisible: "Pause" + timeout: 10000 + - back + - extendedWaitUntil: + visible: "Overview" + timeout: 10000 diff --git a/.maestro/flows/07_profiles_settings.yaml b/.maestro/flows/07_profiles_settings.yaml index 661e8aa5..ecbd5c3b 100644 --- a/.maestro/flows/07_profiles_settings.yaml +++ b/.maestro/flows/07_profiles_settings.yaml @@ -6,7 +6,7 @@ tags: - settings --- - runFlow: ../subflows/onboard_jellyfin.yaml -- tapOn: "M" +- tapOn: "(?s)^M(?:.*Profiles.*)?$" - tapOn: text: "Profiles" above: @@ -21,16 +21,44 @@ tags: timeout: 10000 - tapOn: "e.g. Guests, Kids, Family Room" - inputText: "E2E Guest" -- hideKeyboard +- runFlow: + when: + platform: iOS + commands: + - pressKey: ENTER +- runFlow: + when: + platform: Android + commands: + - hideKeyboard - tapOn: "Continue" - extendedWaitUntil: visible: "Add to E2E Guest" timeout: 10000 - assertVisible: "(?s)^Sign in with Plex.*" - assertVisible: "(?s)^Connect to Jellyfin.*" -- back +- runFlow: + when: + platform: iOS + commands: + - tapOn: "Back" +- runFlow: + when: + platform: Android + commands: + - back +- assertVisible: "Switch Profile" - assertVisible: "(?s).*E2E Guest.*" -- back +- runFlow: + when: + platform: iOS + commands: + - tapOn: "Back" +- runFlow: + when: + platform: Android + commands: + - back - extendedWaitUntil: visible: "(?s)^Home.*" timeout: 10000 @@ -41,7 +69,7 @@ tags: while: notVisible: "Settings" commands: - - tapOn: "M" + - tapOn: "(?s)^M(?:.*Profiles.*)?$" - waitForAnimationToEnd: timeout: 3000 - tapOn: @@ -53,10 +81,7 @@ tags: timeout: 10000 - assertVisible: "(?s)^Video Playback.*" - assertVisible: "(?s)^Connections.*" -- tapOn: "(?s)^Appearance.*" -- extendedWaitUntil: - visible: "(?s)^Theme.*" - timeout: 10000 -- assertVisible: "(?s).*Library Density.*" -- back -- assertVisible: "(?s)^Appearance.*" +- runFlow: + when: + platform: Android + file: ../subflows/settings_deep_checks.yaml diff --git a/.maestro/flows/08_logout.yaml b/.maestro/flows/08_logout.yaml index f118f7bd..e6f14c23 100644 --- a/.maestro/flows/08_logout.yaml +++ b/.maestro/flows/08_logout.yaml @@ -5,7 +5,7 @@ tags: - auth --- - runFlow: ../subflows/onboard_jellyfin.yaml -- tapOn: "M" +- tapOn: "(?s)^M(?:.*Profiles.*)?$" - tapOn: text: "Logout" below: diff --git a/.maestro/flows/09_download_offline_playback.yaml b/.maestro/flows/09_download_offline_playback.yaml new file mode 100644 index 00000000..cf8c906e --- /dev/null +++ b/.maestro/flows/09_download_offline_playback.yaml @@ -0,0 +1,77 @@ +appId: com.edde746.plezy +name: Download and play a movie from Downloads + +tags: + - e2e + - downloads + - playback +--- +- runFlow: ../subflows/onboard_jellyfin.yaml +- tapOn: "(?s)^Libraries.*" +- tapOn: "Browse" +- extendedWaitUntil: + visible: "(?s).*Alpha Archive.*" + timeout: 15000 +- tapOn: "(?s).*Alpha Archive.*" +- extendedWaitUntil: + visible: "Overview" + timeout: 15000 +- tapOn: "Download" +- runFlow: + when: + platform: iOS + commands: + - tapOn: + point: "6%, 9%" +- runFlow: + when: + platform: Android + commands: + - back +- extendedWaitUntil: + visible: "Maestro Movies" + timeout: 10000 +- tapOn: "(?s)^Downloads.*" +- tapOn: + text: "Movies" + waitToSettleTimeoutMs: 3000 +- extendedWaitUntil: + visible: "(?s).*Alpha Archive.*" + timeout: 60000 +- runScript: + file: ../scripts/set_jellyfin_offline.js + env: + JELLYFIN_CONTROL_URL: ${JELLYFIN_CONTROL_URL} +- waitForAnimationToEnd: + timeout: 3000 +- tapOn: "(?s).*Alpha Archive.*" +- extendedWaitUntil: + visible: "Overview" + timeout: 15000 +- tapOn: + text: "Play" + waitToSettleTimeoutMs: 1000 +- extendedWaitUntil: + visible: "Pause" + timeout: 30000 +- runFlow: + when: + platform: iOS + commands: + - tapOn: + point: "90%, 5%" + - extendedWaitUntil: + visible: "Overview" + timeout: 10000 +- runFlow: + when: + platform: Android + commands: + - back + - extendedWaitUntil: + notVisible: "Pause" + timeout: 10000 + - back + - extendedWaitUntil: + visible: "Overview" + timeout: 10000 diff --git a/.maestro/media_flows/01_dv_uhd.yaml b/.maestro/media_flows/01_dv_uhd.yaml index 46b7a301..ba1ac209 100644 --- a/.maestro/media_flows/01_dv_uhd.yaml +++ b/.maestro/media_flows/01_dv_uhd.yaml @@ -13,11 +13,13 @@ tags: - runFlow: file: ../subflows/switch_codec_tracks.yaml env: - AUDIO_TRACK: "(?is)^English.*(?:AC3|TrueHD|Surround|5\\.1|7\\.1).*" - SUBTITLE_TRACK: "(?is)^Spanish.*PGS.*" -- repeat: - times: 2 - while: + AUDIO_TRACK: "(?is)^English\\s+[^,]*(?:AC3|TrueHD|Surround|5\\.1|7\\.1)[^,]*$" + SUBTITLE_TRACK: "(?is)^Spanish\\s+[^,]*(?:PGS|APPLICATION/PGS)[^,]*$" +- back +- waitForAnimationToEnd: + timeout: 3000 +- runFlow: + when: notVisible: "Overview" commands: - back diff --git a/.maestro/media_flows/02_dv_web.yaml b/.maestro/media_flows/02_dv_web.yaml index 77d6d68e..bcfb33f0 100644 --- a/.maestro/media_flows/02_dv_web.yaml +++ b/.maestro/media_flows/02_dv_web.yaml @@ -13,11 +13,13 @@ tags: - runFlow: file: ../subflows/switch_codec_tracks.yaml env: - AUDIO_TRACK: "(?is)^German.*(?:E-AC3|Dolby Digital Plus|Surround|5\\.1).*" - SUBTITLE_TRACK: "(?is)^German.*(?:SRT|APPLICATION/X-SUBRIP).*" -- repeat: - times: 2 - while: + AUDIO_TRACK: "(?is)^German\\s+[^,]*(?:E-AC3|Dolby Digital Plus|Surround|5\\.1)[^,]*$" + SUBTITLE_TRACK: "(?is)^German\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$" +- back +- waitForAnimationToEnd: + timeout: 3000 +- runFlow: + when: notVisible: "Overview" commands: - back diff --git a/.maestro/media_flows/03_av1.yaml b/.maestro/media_flows/03_av1.yaml index b1218679..d41dcd55 100644 --- a/.maestro/media_flows/03_av1.yaml +++ b/.maestro/media_flows/03_av1.yaml @@ -13,11 +13,13 @@ tags: - runFlow: file: ../subflows/switch_codec_tracks.yaml env: - AUDIO_TRACK: "(?is)^English.*(?:Opus|Stereo).*" - SUBTITLE_TRACK: "(?is)^English.*(?:ASS|TEXT/X-SSA).*" -- repeat: - times: 2 - while: + AUDIO_TRACK: "(?is)^English\\s+[^,]*(?:Opus|Stereo)[^,]*$" + SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:ASS|TEXT/X-SSA)[^,]*$" +- back +- waitForAnimationToEnd: + timeout: 3000 +- runFlow: + when: notVisible: "Overview" commands: - back diff --git a/.maestro/media_flows/04_h264_high10.yaml b/.maestro/media_flows/04_h264_high10.yaml index 047e19d7..92092eeb 100644 --- a/.maestro/media_flows/04_h264_high10.yaml +++ b/.maestro/media_flows/04_h264_high10.yaml @@ -13,11 +13,13 @@ tags: - runFlow: file: ../subflows/switch_codec_tracks.yaml env: - AUDIO_TRACK: "(?is)^Japanese.*(?:DTS-HD MA|DTS|Surround|5\\.1).*" - SUBTITLE_TRACK: "(?is)^English.*(?:ASS|TEXT/X-SSA).*" -- repeat: - times: 2 - while: + AUDIO_TRACK: "(?is)^Japanese\\s+[^,]*(?:DTS-HD MA|DTS|Surround|5\\.1)[^,]*$" + SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:ASS|TEXT/X-SSA)[^,]*$" +- back +- waitForAnimationToEnd: + timeout: 3000 +- runFlow: + when: notVisible: "Overview" commands: - back diff --git a/.maestro/media_flows/05_h264_eac3.yaml b/.maestro/media_flows/05_h264_eac3.yaml index 034b3759..b3e20cd6 100644 --- a/.maestro/media_flows/05_h264_eac3.yaml +++ b/.maestro/media_flows/05_h264_eac3.yaml @@ -13,12 +13,14 @@ tags: - runFlow: file: ../subflows/switch_codec_tracks_deep.yaml env: - AUDIO_TRACK: "(?is)^English.*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1).*" - SUBTITLE_TRACK: "(?is)^English.*(?:SRT|APPLICATION/X-SUBRIP).*" - SHEET_MARKER: "(?is)^Japanese.*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1).*" -- repeat: - times: 2 - while: + AUDIO_TRACK: "(?is)^English\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$" + SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$" + SHEET_MARKER: "(?is)^Japanese\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$" +- back +- waitForAnimationToEnd: + timeout: 3000 +- runFlow: + when: notVisible: "Overview" commands: - back diff --git a/.maestro/media_flows/06_hevc_flac.yaml b/.maestro/media_flows/06_hevc_flac.yaml index 2aac99b1..e6e826b5 100644 --- a/.maestro/media_flows/06_hevc_flac.yaml +++ b/.maestro/media_flows/06_hevc_flac.yaml @@ -13,11 +13,13 @@ tags: - runFlow: file: ../subflows/switch_codec_tracks.yaml env: - AUDIO_TRACK: "(?is)^Japanese.*(?:FLAC|Surround|5\\.1).*" - SUBTITLE_TRACK: "(?is)^English.*(?:ASS|TEXT/X-SSA).*" -- repeat: - times: 2 - while: + AUDIO_TRACK: "(?is)^Japanese\\s+[^,]*(?:FLAC|Surround|5\\.1)[^,]*$" + SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:ASS|TEXT/X-SSA)[^,]*$" +- back +- waitForAnimationToEnd: + timeout: 3000 +- runFlow: + when: notVisible: "Overview" commands: - back diff --git a/.maestro/real_flows/01_media_catalog.yaml b/.maestro/real_flows/01_media_catalog.yaml index fb5934bb..47c663d1 100644 --- a/.maestro/real_flows/01_media_catalog.yaml +++ b/.maestro/real_flows/01_media_catalog.yaml @@ -14,9 +14,9 @@ tags: - inputText: "Codec DV UHD" - hideKeyboard - extendedWaitUntil: - visible: "(?s).*Codec DV UHD TrueHD PGS$" + visible: "(?s).*Codec DV UHD TrueHD PGS.*" timeout: 15000 -- tapOn: "(?s).*Codec DV UHD TrueHD PGS$" +- tapOn: "(?s).*Codec DV UHD TrueHD PGS.*" - extendedWaitUntil: visible: "Overview" timeout: 15000 diff --git a/.maestro/regression_flows/01_profile_switch_isolation.yaml b/.maestro/regression_flows/01_profile_switch_isolation.yaml index c7eb06f1..03adfc39 100644 --- a/.maestro/regression_flows/01_profile_switch_isolation.yaml +++ b/.maestro/regression_flows/01_profile_switch_isolation.yaml @@ -7,7 +7,7 @@ tags: - profiles --- - runFlow: ../subflows/onboard_jellyfin.yaml -- assertVisible: "(?s).*Zulu Zone 4.*" +- assertVisible: "(?s).*Zulu Zone.*" - tapOn: "(?s)^Libraries.*" - extendedWaitUntil: visible: "Maestro Movies" @@ -17,9 +17,9 @@ tags: - runFlow: ../subflows/create_guest_jellyfin_profile.yaml - tapOn: "(?s)^E\nE2E Guest\n.*" - extendedWaitUntil: - visible: "(?s).*Guest Galaxy$" + visible: "(?s).*Guest Galaxy.*" timeout: 30000 -- assertNotVisible: "(?s).*Zulu Zone 4.*" +- assertNotVisible: "(?s).*Zulu Zone.*" - tapOn: "(?s)^Libraries.*" - extendedWaitUntil: visible: "Guest Movies" @@ -27,7 +27,7 @@ tags: - assertNotVisible: "Maestro Movies" - tapOn: "(?s)^Home.*" - extendedWaitUntil: - visible: "(?s).*Guest Galaxy$" + visible: "(?s).*Guest Galaxy.*" timeout: 15000 - tapOn: "E" - tapOn: @@ -39,9 +39,9 @@ tags: timeout: 10000 - tapOn: "(?s)^M\nMaestro\n.*" - extendedWaitUntil: - visible: "(?s).*Zulu Zone 4.*" + visible: "(?s).*Zulu Zone.*" timeout: 30000 -- assertNotVisible: "(?s).*Guest Galaxy$" +- assertNotVisible: "(?s).*Guest Galaxy.*" - tapOn: "(?s)^Libraries.*" - extendedWaitUntil: visible: "Maestro Movies" @@ -49,7 +49,7 @@ tags: - assertNotVisible: "Guest Movies" - tapOn: "(?s)^Home.*" - extendedWaitUntil: - visible: "(?s).*Zulu Zone 4.*" + visible: "(?s).*Zulu Zone.*" timeout: 15000 - tapOn: "M" - tapOn: @@ -61,6 +61,6 @@ tags: timeout: 10000 - tapOn: "(?s)^E\nE2E Guest\n.*" - extendedWaitUntil: - visible: "(?s).*Guest Galaxy$" + visible: "(?s).*Guest Galaxy.*" timeout: 30000 -- assertNotVisible: "(?s).*Zulu Zone 4.*" +- assertNotVisible: "(?s).*Zulu Zone.*" diff --git a/.maestro/regression_flows/02_profile_teardown.yaml b/.maestro/regression_flows/02_profile_teardown.yaml index 3f35d024..9425e9ba 100644 --- a/.maestro/regression_flows/02_profile_teardown.yaml +++ b/.maestro/regression_flows/02_profile_teardown.yaml @@ -17,7 +17,7 @@ tags: visible: "Profile name" timeout: 10000 - assertVisible: "(?s)^E2E Guest.*" -- assertVisible: "(?s)^Maestro Jellyfin.*Default.*Manage$" +- assertVisible: "(?s)^Maestro Jellyfin.*Default.*$" - tapOn: "Manage" - extendedWaitUntil: visible: "Remove" @@ -29,13 +29,16 @@ tags: - assertVisible: "(?s).*E2E Guest's access to Maestro Jellyfin.*" - tapOn: "Remove" - extendedWaitUntil: - notVisible: "(?s)^Maestro Jellyfin.*Default.*Manage$" + notVisible: "(?s)^Maestro Jellyfin.*" timeout: 15000 - assertVisible: "Delete profile" -- tapOn: "Delete profile" -- extendedWaitUntil: - visible: "^Delete profile\\?$" - timeout: 10000 +- retry: + maxRetries: 2 + commands: + - tapOn: "Delete profile" + - extendedWaitUntil: + visible: "^Delete profile\\?$" + timeout: 10000 - tapOn: "^Delete$" - extendedWaitUntil: visible: "Switch Profile" @@ -44,5 +47,5 @@ tags: - assertVisible: "(?s)^M\nMaestro\nActive.*" - back - extendedWaitUntil: - visible: "(?s).*Zulu Zone 4.*" + visible: "(?s)^Recently Added in Maestro Movies.*" timeout: 30000 diff --git a/.maestro/regression_flows/04_tv_player_keys.yaml b/.maestro/regression_flows/04_tv_player_keys.yaml index ef626d1c..76227c2a 100644 --- a/.maestro/regression_flows/04_tv_player_keys.yaml +++ b/.maestro/regression_flows/04_tv_player_keys.yaml @@ -9,16 +9,20 @@ tags: --- - runFlow: ../subflows/onboard_jellyfin_tv.yaml - pressKey: "Remote Dpad Center" -- extendedWaitUntil: - visible: "Play" - timeout: 15000 -- pressKey: "Remote Dpad Center" +- waitForAnimationToEnd: + timeout: 5000 +- runFlow: + when: + visible: "(?s)^(Play|Resume).*$" + commands: + - pressKey: "Remote Dpad Center" - extendedWaitUntil: visible: "Pause" timeout: 30000 +- pressKey: "back" - extendedWaitUntil: notVisible: "Pause" - timeout: 15000 + timeout: 10000 - pressKey: "Remote Media Play Pause" - extendedWaitUntil: visible: "Play" @@ -33,7 +37,14 @@ tags: timeout: 10000 - assertNotVisible: "Overview" - pressKey: "back" +- waitForAnimationToEnd: + timeout: 5000 +- runFlow: + when: + visible: "Overview" + commands: + - pressKey: "back" - extendedWaitUntil: - visible: "(?s)^(Play|Resume).*$" + visible: + id: "tv_browse_rail_selection" timeout: 15000 -- assertVisible: "(?s).*Zulu Zone 4.*" diff --git a/.maestro/regression_flows/05_tv_next_episode_back.yaml b/.maestro/regression_flows/05_tv_next_episode_back.yaml index d232d423..ffc757a8 100644 --- a/.maestro/regression_flows/05_tv_next_episode_back.yaml +++ b/.maestro/regression_flows/05_tv_next_episode_back.yaml @@ -18,14 +18,12 @@ tags: - tapOn: point: "87%,90%" - extendedWaitUntil: - visible: "(?s).*Maestro Show$" + visible: "(?s)^Maestro Show, TV show, (?:watched|unwatched)$" timeout: 15000 -- tapOn: - point: "50%,24%" +- tapOn: "(?s)^Maestro Show, TV show, (?:watched|unwatched)$" - waitForAnimationToEnd: timeout: 5000 -- tapOn: - point: "15%,76%" +- tapOn: "(?s)^Play S1E1$" - extendedWaitUntil: visible: "Pause" timeout: 30000 @@ -34,7 +32,16 @@ tags: - extendedWaitUntil: visible: "Next Episode" timeout: 20000 -- pressKey: "back" +- runFlow: + when: + platform: iOS + commands: + - tapOn: "Cancel" +- runFlow: + when: + platform: Android + commands: + - pressKey: "back" - assertNotVisible: "Next Episode" - assertNotVisible: "Overview" - pressKey: "Remote Dpad Up" diff --git a/.maestro/regression_flows/06_playback_recovery.yaml b/.maestro/regression_flows/06_playback_recovery.yaml index fce18d0e..f6c7b3c7 100644 --- a/.maestro/regression_flows/06_playback_recovery.yaml +++ b/.maestro/regression_flows/06_playback_recovery.yaml @@ -9,9 +9,9 @@ tags: - runFlow: ../subflows/onboard_jellyfin.yaml - tapOn: "(?s)^Libraries.*" - extendedWaitUntil: - visible: "(?s).*Zulu Zone 4$" + visible: "(?s).*Zulu Zone.*" timeout: 15000 -- tapOn: "(?s).*Zulu Zone 4$" +- tapOn: "(?s).*Zulu Zone.*" - extendedWaitUntil: visible: "Play" timeout: 15000 diff --git a/.maestro/scripts/set_jellyfin_offline.js b/.maestro/scripts/set_jellyfin_offline.js new file mode 100644 index 00000000..cd72e42a --- /dev/null +++ b/.maestro/scripts/set_jellyfin_offline.js @@ -0,0 +1,11 @@ +const controlUrl = String(JELLYFIN_CONTROL_URL).trim(); + +if (controlUrl !== "" && controlUrl !== "undefined" && controlUrl !== "null") { + const response = http.post(`${controlUrl}/__maestro/offline`, { + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({enabled: true}), + }); + if (response.status !== 204) { + throw new Error(`Could not put Jellyfin fixture offline: HTTP ${response.status}`); + } +} diff --git a/.maestro/subflows/onboard_jellyfin.yaml b/.maestro/subflows/onboard_jellyfin.yaml index 881340c7..0ee88b69 100644 --- a/.maestro/subflows/onboard_jellyfin.yaml +++ b/.maestro/subflows/onboard_jellyfin.yaml @@ -30,11 +30,13 @@ appId: com.edde746.plezy - inputText: "maestro" - tapOn: "Password" - inputText: "maestro" -- hideKeyboard: - optional: true -- tapOn: - text: "Sign in" - optional: true -- extendedWaitUntil: - visible: "Discover" - timeout: 30000 +- pressKey: ENTER +- retry: + maxRetries: 1 + commands: + - tapOn: + text: "Sign in" + optional: true + - extendedWaitUntil: + visible: "Discover" + timeout: 30000 diff --git a/.maestro/subflows/onboard_jellyfin_tv.yaml b/.maestro/subflows/onboard_jellyfin_tv.yaml index c09f4994..b2f2746a 100644 --- a/.maestro/subflows/onboard_jellyfin_tv.yaml +++ b/.maestro/subflows/onboard_jellyfin_tv.yaml @@ -50,5 +50,6 @@ appId: com.edde746.plezy duration: 500 - tapOn: "(?s)^Force TV mode.*" - extendedWaitUntil: - visible: "(?s).*Zulu Zone 4.*" + visible: + id: "tv_browse_rail_selection" timeout: 30000 diff --git a/.maestro/subflows/open_codec_sample.yaml b/.maestro/subflows/open_codec_sample.yaml index fecc53a4..843e28d2 100644 --- a/.maestro/subflows/open_codec_sample.yaml +++ b/.maestro/subflows/open_codec_sample.yaml @@ -7,11 +7,20 @@ appId: com.edde746.plezy timeout: 10000 - tapOn: "Search movies, shows, music..." - inputText: "${SAMPLE_TITLE}" +- runFlow: + when: + platform: iOS + commands: + - pressKey: ENTER +- runFlow: + when: + platform: Android + commands: + - hideKeyboard - extendedWaitUntil: - visible: "(?s).*${SAMPLE_TITLE}$" + visible: "(?s)^${SAMPLE_TITLE}, movie, (?:watched|unwatched)$" timeout: 15000 -- tapOn: - point: "50%,24%" +- tapOn: "(?s)^${SAMPLE_TITLE}, movie, (?:watched|unwatched)$" - extendedWaitUntil: visible: "Overview" timeout: 15000 @@ -22,4 +31,14 @@ appId: com.edde746.plezy - extendedWaitUntil: notVisible: "Overview" timeout: 15000 -- pressKey: "Remote Media Play Pause" +- extendedWaitUntil: + visible: "Pause" + timeout: 30000 +- tapOn: "Pause" +- extendedWaitUntil: + visible: "Play" + timeout: 5000 +- extendedWaitUntil: + visible: "(?s)^.*Audio & Subtitles$" + timeout: 30000 +- tapOn: "(?s)^.*Audio & Subtitles$" diff --git a/.maestro/subflows/settings_deep_checks.yaml b/.maestro/subflows/settings_deep_checks.yaml new file mode 100644 index 00000000..ab76cc93 --- /dev/null +++ b/.maestro/subflows/settings_deep_checks.yaml @@ -0,0 +1,60 @@ +appId: com.edde746.plezy +--- +- tapOn: "(?s)^Appearance.*" +- extendedWaitUntil: + visible: "(?s).*Theme.*" + timeout: 10000 +- assertVisible: "(?s).*Library Density.*" +- back +- assertVisible: "(?s)^Appearance.*" +- tapOn: "(?s)^Video Playback.*" +- extendedWaitUntil: + visible: "(?s)^Player.*" + timeout: 10000 +- repeat: + times: 4 + while: + notVisible: "(?s)^Subtitles & Configuration.*Subtitle Styling.*" + commands: + - swipe: + start: 50%, 80% + end: 50%, 65% + duration: 800 + - waitForAnimationToEnd: + timeout: 1500 +- assertVisible: "(?s)^Subtitles & Configuration.*Subtitle Styling.*" +- repeat: + times: 4 + while: + notVisible: "(?s)^Seek & Timing.*" + commands: + - swipe: + start: 50%, 80% + end: 50%, 65% + duration: 800 + - waitForAnimationToEnd: + timeout: 1500 +- assertVisible: "(?s)^Seek & Timing.*" +- back +- extendedWaitUntil: + visible: "(?s)^Manage Libraries.*" + timeout: 10000 +- tapOn: "(?s)^Manage Libraries.*" +- extendedWaitUntil: + visible: "(?s)^Manage Libraries.*" + timeout: 10000 +- back +- extendedWaitUntil: + visible: "(?s)^Services.*" + timeout: 10000 +- tapOn: "(?s)^Services.*" +- extendedWaitUntil: + visible: "Services" + timeout: 10000 +- assertVisible: "(?s)^Trakt.*" +- assertVisible: "(?s)^MyAnimeList.*" +- assertVisible: "(?s)^AniList.*" +- assertVisible: "(?s)^Simkl.*" +- assertVisible: "(?s)^Seerr.*" +- back +- assertVisible: "(?s)^Services.*" diff --git a/.maestro/subflows/show_codec_track_sheet.yaml b/.maestro/subflows/show_codec_track_sheet.yaml deleted file mode 100644 index 75f35d2e..00000000 --- a/.maestro/subflows/show_codec_track_sheet.yaml +++ /dev/null @@ -1,21 +0,0 @@ -appId: com.edde746.plezy ---- -- runFlow: - when: - notVisible: "${SHEET_MARKER}" - commands: - - repeat: - times: 8 - while: - notVisible: "${SHEET_MARKER}" - commands: - - tapOn: - point: "50%,50%" - - runFlow: - when: - visible: "Pause" - commands: - - pressKey: "Remote Media Play Pause" - - tapOn: - point: "75%,11%" -- assertVisible: "${SHEET_MARKER}" diff --git a/.maestro/subflows/switch_codec_tracks.yaml b/.maestro/subflows/switch_codec_tracks.yaml index 5f3409c5..2118f7e9 100644 --- a/.maestro/subflows/switch_codec_tracks.yaml +++ b/.maestro/subflows/switch_codec_tracks.yaml @@ -1,23 +1,19 @@ appId: com.edde746.plezy --- -- runFlow: - file: show_codec_track_sheet.yaml - env: - SHEET_MARKER: "${AUDIO_TRACK}" - assertVisible: "${AUDIO_TRACK}" - tapOn: - point: "30%,70%" -- runFlow: - file: show_codec_track_sheet.yaml - env: - SHEET_MARKER: "${AUDIO_TRACK}" + text: "${AUDIO_TRACK}" +- extendedWaitUntil: + visible: "(?s)^.*Audio & Subtitles$" + timeout: 30000 +- tapOn: "(?s)^.*Audio & Subtitles$" - assertVisible: "${SUBTITLE_TRACK}" - tapOn: text: "${SUBTITLE_TRACK}" -- runFlow: - file: show_codec_track_sheet.yaml - env: - SHEET_MARKER: "${AUDIO_TRACK}" +- extendedWaitUntil: + visible: "(?s)^.*Audio & Subtitles$" + timeout: 30000 +- tapOn: "(?s)^.*Audio & Subtitles$" - assertVisible: text: "${AUDIO_TRACK}" selected: true diff --git a/.maestro/subflows/switch_codec_tracks_deep.yaml b/.maestro/subflows/switch_codec_tracks_deep.yaml index c133ada5..b6eade15 100644 --- a/.maestro/subflows/switch_codec_tracks_deep.yaml +++ b/.maestro/subflows/switch_codec_tracks_deep.yaml @@ -1,16 +1,12 @@ appId: com.edde746.plezy --- -- runFlow: - file: show_codec_track_sheet.yaml - env: - SHEET_MARKER: "${SHEET_MARKER}" - assertVisible: "${AUDIO_TRACK}" - tapOn: - point: "30%,84%" -- runFlow: - file: show_codec_track_sheet.yaml - env: - SHEET_MARKER: "${AUDIO_TRACK}" + text: "${AUDIO_TRACK}" +- extendedWaitUntil: + visible: "(?s)^.*Audio & Subtitles$" + timeout: 30000 +- tapOn: "(?s)^.*Audio & Subtitles$" - repeat: times: 12 while: @@ -23,13 +19,22 @@ appId: com.edde746.plezy - assertVisible: "${SUBTITLE_TRACK}" - tapOn: text: "${SUBTITLE_TRACK}" -- runFlow: - file: show_codec_track_sheet.yaml - env: - SHEET_MARKER: "${AUDIO_TRACK}" +- extendedWaitUntil: + visible: "(?s)^.*Audio & Subtitles$" + timeout: 30000 +- tapOn: "(?s)^.*Audio & Subtitles$" - assertVisible: text: "${AUDIO_TRACK}" selected: true +- repeat: + times: 12 + while: + notVisible: "${SUBTITLE_TRACK}" + commands: + - swipe: + start: 70%, 82% + end: 70%, 68% + duration: 300 - assertVisible: text: "${SUBTITLE_TRACK}" selected: true diff --git a/scripts/maestro_jellyfin_proxy.py b/scripts/maestro_jellyfin_proxy.py index b76be4a3..01b4cb11 100755 --- a/scripts/maestro_jellyfin_proxy.py +++ b/scripts/maestro_jellyfin_proxy.py @@ -18,6 +18,7 @@ import urllib.request _FAULT_PATHS = { "music-failure": lambda path: path.startswith("/Artists/AlbumArtists"), "recovery": lambda path: path.startswith("/Videos/") and "/stream" in path, + "offline": lambda _path: False, } _FORWARD_HEADERS = { "accept", @@ -50,6 +51,7 @@ class ProxyState: self.journal = journal self._fault_injected = False self._sequence = 0 + self._offline_enabled = False self._lock = threading.Lock() if journal is not None: journal.parent.mkdir(parents=True, exist_ok=True) @@ -65,6 +67,14 @@ class ProxyState: self._fault_injected = True return True + def set_offline(self, enabled: bool) -> None: + with self._lock: + self._offline_enabled = enabled + + def is_offline(self) -> bool: + with self._lock: + return self.fault == "offline" and self._offline_enabled + def record(self, *, method: str, path: str, status: int, kind: str) -> None: if self.journal is None: return @@ -111,15 +121,28 @@ class JellyfinProxyHandler(BaseHTTPRequestHandler): self._proxy() def _proxy(self) -> None: - if self.state.should_fault(self.path): - payload = json.dumps({"error": "temporary Maestro fault"}).encode("utf-8") + if urllib.parse.urlsplit(self.path).path == "/__maestro/offline": + self._set_offline() + return + if self.state.is_offline(): + payload = json.dumps({"error": "Maestro offline mode"}).encode("utf-8") + self.state.record(method=self.command, path=self.path, status=503, kind="offline") self.send_response(HTTPStatus.SERVICE_UNAVAILABLE) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(payload))) self.end_headers() if self.command != "HEAD": self.wfile.write(payload) + return + if self.state.should_fault(self.path): + payload = json.dumps({"error": "temporary Maestro fault"}).encode("utf-8") self.state.record(method=self.command, path=self.path, status=503, kind="fault") + self.send_response(HTTPStatus.SERVICE_UNAVAILABLE) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(payload) return content_length = int(self.headers.get("Content-Length", "0")) @@ -149,6 +172,7 @@ class JellyfinProxyHandler(BaseHTTPRequestHandler): status = HTTPStatus.BAD_GATEWAY response_headers = {"Content-Type": "application/json"} + self.state.record(method=self.command, path=self.path, status=int(status), kind="request") self.send_response(status) for name, value in response_headers.items(): if name.lower() in _RESPONSE_HEADERS: @@ -160,7 +184,19 @@ class JellyfinProxyHandler(BaseHTTPRequestHandler): self.wfile.write(payload) except (BrokenPipeError, ConnectionResetError): pass - self.state.record(method=self.command, path=self.path, status=int(status), kind="request") + + def _set_offline(self) -> None: + content_length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(content_length) if content_length else b"{}" + try: + enabled = bool(json.loads(body).get("enabled", True)) + except (AttributeError, json.JSONDecodeError): + self.send_error(HTTPStatus.BAD_REQUEST) + return + self.state.set_offline(enabled) + self.state.record(method=self.command, path=self.path, status=204, kind="control") + self.send_response(HTTPStatus.NO_CONTENT) + self.end_headers() def log_message(self, format: str, *args: Any) -> None: print(f"jellyfin-proxy: {format % args}") diff --git a/scripts/run_maestro.py b/scripts/run_maestro.py index 387d62bb..628b1056 100755 --- a/scripts/run_maestro.py +++ b/scripts/run_maestro.py @@ -20,7 +20,7 @@ import urllib.request ROOT_DIR = Path(__file__).resolve().parent.parent APP_ID = "com.edde746.plezy" -FAULTS = ("music-failure", "recovery") +FAULTS = ("music-failure", "offline", "recovery") class RunnerError(RuntimeError): @@ -316,6 +316,16 @@ def _require_commands(names: Sequence[str]) -> None: raise RunnerError(f"Required command not found: {', '.join(missing)}") +def flutter_build_command() -> tuple[str, ...]: + return ( + "flutter", + "build", + "apk", + "--debug", + "--dart-define=PLEZY_MAESTRO_E2E=true", + ) + + def build_jellyfin_image(config: RunnerConfig) -> None: _require_commands(("docker",)) command = ( @@ -373,14 +383,14 @@ class MaestroRunner: if not self.config.skip_jellyfin_build: build_jellyfin_image(self.config) self._start_jellyfin() - self._wait_for_health(self.host_jellyfin_url, attempts=120, interval=0.25, service="Jellyfin") + self._wait_for_health(self.host_jellyfin_url, attempts=180, interval=1, service="Jellyfin") if self.config.jellyfin_fault: self._start_proxy() if not self.config.skip_build: _run_checked(("flutter", "pub", "get")) - _run_checked(("flutter", "build", "apk", "--debug")) + _run_checked(flutter_build_command()) self._prepare_device() _run_checked((*self.adb_prefix, "install", "-r", self.config.apk_path)) @@ -392,6 +402,8 @@ class MaestroRunner: default_url = f"http://127.0.0.1:{self.device_service_port}" jellyfin_url = self.config.jellyfin_url or default_url command = ["maestro", "test", "-e", f"JELLYFIN_URL={jellyfin_url}"] + if self.config.jellyfin_fault == "offline": + command.extend(("-e", f"JELLYFIN_CONTROL_URL={self.host_jellyfin_url}")) if self.device_id: command.extend(("--device", self.device_id)) if self.config.maestro_config: @@ -451,11 +463,13 @@ class MaestroRunner: for _ in range(attempts): try: with urllib.request.urlopen(health_url, timeout=1) as response: - response.read() - return + status = response.read().decode(errors="replace").strip() + if status.casefold() == "healthy": + return + last_error = RunnerError(f"{service} health status is {status or 'empty'}") except (OSError, urllib.error.URLError) as error: last_error = error - time.sleep(interval) + time.sleep(interval) raise RunnerError(f"{service} did not become ready at {base_url}: {last_error}") def _start_proxy(self) -> None: diff --git a/scripts/run_maestro_ci.py b/scripts/run_maestro_ci.py index 7997e325..84b0d6e9 100755 --- a/scripts/run_maestro_ci.py +++ b/scripts/run_maestro_ci.py @@ -9,6 +9,12 @@ from collections.abc import Sequence import run_maestro +ANDROID_15_INSTRUMENTATION_CLASSES = ( + "androidx.media3.decoder.ffmpeg.PlezyFfmpegPlaybackTest," + "com.edde746.plezy.exoplayer.PlezyAudioModePlaybackTest" +) + + GROUPS: dict[str, tuple[tuple[str, ...], ...]] = { "android-15": ( ("basic",), @@ -54,6 +60,21 @@ GROUPS: dict[str, tuple[tuple[str, ...], ...]] = { "--diagnostics-dir", "build/maestro-recovery/music-diagnostics", ), + ( + "basic", + "--fault", + "offline", + "--flow", + ".maestro/flows/09_download_offline_playback.yaml", + "--jellyfin-log", + "build/maestro-offline/jellyfin.log", + "--proxy-log", + "build/maestro-offline/jellyfin-proxy.log", + "--proxy-journal", + "build/maestro-offline/proxy-journal.jsonl", + "--diagnostics-dir", + "build/maestro-offline/diagnostics", + ), ( "basic", "--fault", @@ -82,7 +103,24 @@ GROUPS: dict[str, tuple[tuple[str, ...], ...]] = { } +def run_android_15_instrumentation() -> None: + print("==> Android 15 filtered instrumentation", flush=True) + run_maestro._run_checked( + ( + "android/gradlew", + "-p", + "android", + ":app:connectedDebugAndroidTest", + "-x", + ":app:compileFlutterBuildDebug", + f"-Pandroid.testInstrumentationRunnerArguments.class={ANDROID_15_INSTRUMENTATION_CLASSES}", + ) + ) + + def run_group(name: str) -> int: + if name == "android-15": + run_android_15_instrumentation() failed = False for arguments in GROUPS[name]: print(f"==> Maestro {' '.join(arguments)}", flush=True) diff --git a/scripts/test_maestro_flow_contracts.py b/scripts/test_maestro_flow_contracts.py new file mode 100755 index 00000000..c591ed0f --- /dev/null +++ b/scripts/test_maestro_flow_contracts.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import ast +from pathlib import Path +import re +import unittest +from typing import Any + + +ROOT_DIR = Path(__file__).resolve().parents[1] + + +def _mapping_separator(value: str) -> int | None: + quote: str | None = None + escaped = False + for index, character in enumerate(value): + if escaped: + escaped = False + continue + if character == "\\" and quote is not None: + escaped = True + continue + if character in {'"', "'"}: + if quote is None: + quote = character + elif quote == character: + quote = None + continue + if character == ":" and quote is None and ( + index + 1 == len(value) or value[index + 1].isspace() + ): + return index + return None + + +def _scalar(value: str) -> Any: + if value.startswith(('"', "'")): + return ast.literal_eval(value) + if value == "true": + return True + if value == "false": + return False + if value in {"null", "~"}: + return None + if re.fullmatch(r"-?\d+", value): + return int(value) + return value + + +def _parse_node(lines: list[tuple[int, str]], index: int, indent: int) -> tuple[Any, int]: + if lines[index][0] != indent: + raise ValueError(f"unexpected indentation at line {index + 1}") + if lines[index][1].startswith("-"): + result: list[Any] = [] + while index < len(lines) and lines[index][0] == indent and lines[index][1].startswith("-"): + item = lines[index][1][1:].lstrip() + index += 1 + separator = _mapping_separator(item) + if separator is None: + result.append(_scalar(item)) + continue + + key = item[:separator] + value = item[separator + 1 :].lstrip() + if value: + result.append({key: _scalar(value)}) + elif index < len(lines) and lines[index][0] > indent: + child, index = _parse_node(lines, index, lines[index][0]) + result.append({key: child}) + else: + result.append({key: None}) + return result, index + + result_map: dict[str, Any] = {} + while index < len(lines) and lines[index][0] == indent and not lines[index][1].startswith("-"): + item = lines[index][1] + separator = _mapping_separator(item) + if separator is None: + raise ValueError(f"expected mapping at line {index + 1}") + key = item[:separator] + value = item[separator + 1 :].lstrip() + index += 1 + if value: + result_map[key] = _scalar(value) + elif index < len(lines) and lines[index][0] > indent: + result_map[key], index = _parse_node(lines, index, lines[index][0]) + else: + result_map[key] = None + return result_map, index + + +def load_flow(relative_path: str) -> list[dict[str, Any]]: + contents = (ROOT_DIR / relative_path).read_text(encoding="utf-8") + try: + commands = contents.split("---", maxsplit=1)[1] + except IndexError as error: + raise ValueError(f"{relative_path} has no Maestro command document") from error + lines = [ + (len(line) - len(line.lstrip(" ")), line.lstrip(" ")) + for line in commands.splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + parsed, next_index = _parse_node(lines, 0, 0) + if next_index != len(lines) or not isinstance(parsed, list): + raise ValueError(f"could not parse all commands in {relative_path}") + return parsed + + +def command_name(step: dict[str, Any]) -> str: + if len(step) != 1: + raise ValueError(f"expected one command per step, got {step!r}") + return next(iter(step)) + + +def platform_pair(steps: list[dict[str, Any]], index: int) -> dict[str, list[dict[str, Any]]]: + pair = steps[index : index + 2] + if len(pair) != 2 or [command_name(step) for step in pair] != ["runFlow", "runFlow"]: + raise AssertionError(f"expected adjacent platform runFlow pair at command {index}") + branches = {step["runFlow"]["when"]["platform"]: step["runFlow"]["commands"] for step in pair} + if set(branches) != {"iOS", "Android"}: + raise AssertionError(f"expected iOS and Android branches, got {set(branches)}") + return branches + + +class MaestroFlowContractTests(unittest.TestCase): + def test_tv_result_selector_cannot_select_the_query_field(self) -> None: + steps = load_flow(".maestro/regression_flows/05_tv_next_episode_back.yaml") + observation = next( + step["extendedWaitUntil"] + for step in steps + if command_name(step) == "extendedWaitUntil" + and isinstance(step["extendedWaitUntil"], dict) + and "TV show" in str(step["extendedWaitUntil"].get("visible", "")) + ) + selector = observation["visible"] + + self.assertIsNotNone(re.fullmatch(selector, "Maestro Show, TV show, unwatched")) + self.assertIsNotNone(re.fullmatch(selector, "Maestro Show, TV show, watched")) + self.assertIsNone(re.fullmatch(selector, "Maestro Show")) + self.assertIn({"tapOn": selector}, steps) + + def test_offline_fault_is_injected_after_movies_tab_content_is_observed(self) -> None: + steps = load_flow(".maestro/flows/09_download_offline_playback.yaml") + movies_index = next( + index + for index, step in enumerate(steps) + if step.get("tapOn") == {"text": "Movies", "waitToSettleTimeoutMs": 3000} + ) + observation_index = next( + index + for index, step in enumerate(steps[movies_index + 1 :], movies_index + 1) + if step.get("extendedWaitUntil", {}).get("visible") == "(?s).*Alpha Archive.*" + ) + offline_index = next( + index + for index, step in enumerate(steps) + if step.get("runScript", {}).get("file") == "../scripts/set_jellyfin_offline.js" + ) + + self.assertLess(movies_index, observation_index) + self.assertLess(observation_index, offline_index) + self.assertEqual(steps[observation_index]["extendedWaitUntil"]["timeout"], 60000) + + def test_tv_next_episode_dismissal_has_platform_specific_controls(self) -> None: + steps = load_flow(".maestro/regression_flows/05_tv_next_episode_back.yaml") + next_episode_index = next( + index + for index, step in enumerate(steps) + if step.get("extendedWaitUntil", {}).get("visible") == "Next Episode" + ) + branches = platform_pair(steps, next_episode_index + 1) + + self.assertEqual(branches["iOS"], [{"tapOn": "Cancel"}]) + self.assertEqual(branches["Android"], [{"pressKey": "back"}]) + + def test_offline_flow_back_and_close_controls_are_platform_specific(self) -> None: + steps = load_flow(".maestro/flows/09_download_offline_playback.yaml") + platform_pair_indices = [ + index + for index in range(len(steps) - 1) + if command_name(steps[index]) == "runFlow" + and command_name(steps[index + 1]) == "runFlow" + and steps[index]["runFlow"].get("when", {}).get("platform") == "iOS" + and steps[index + 1]["runFlow"].get("when", {}).get("platform") == "Android" + ] + self.assertEqual(len(platform_pair_indices), 2) + detail_close = platform_pair(steps, platform_pair_indices[0]) + player_close = platform_pair(steps, platform_pair_indices[1]) + + self.assertEqual(detail_close["iOS"], [{"tapOn": {"point": "6%, 9%"}}]) + self.assertEqual(detail_close["Android"], ["back"]) + self.assertEqual(player_close["iOS"][0], {"tapOn": {"point": "90%, 5%"}}) + self.assertEqual([command for command in player_close["Android"] if command == "back"], ["back", "back"]) + self.assertNotIn("back", steps) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_maestro_jellyfin_proxy.py b/scripts/test_maestro_jellyfin_proxy.py index ff0e3408..89a590ff 100755 --- a/scripts/test_maestro_jellyfin_proxy.py +++ b/scripts/test_maestro_jellyfin_proxy.py @@ -47,6 +47,16 @@ class _UpstreamHandler(BaseHTTPRequestHandler): def log_message(self, format: str, *args: object) -> None: pass +class _ResponseFailureHandler: + def __init__(self, state: ProxyState, path: str) -> None: + self.state = state + self.path = path + self.command = "GET" + self.headers: dict[str, str] = {} + + def send_response(self, status: int) -> None: + raise BrokenPipeError(f"client disconnected before status {status}") + class JellyfinProxyTests(unittest.TestCase): def setUp(self) -> None: @@ -121,6 +131,40 @@ class JellyfinProxyTests(unittest.TestCase): finally: self._stop_proxy(proxy, thread) + def test_offline_control_blocks_requests_until_reenabled(self) -> None: + proxy, thread, base_url, journal = self._start_proxy("offline") + try: + enable = urllib.request.Request( + base_url + "/__maestro/offline", + data=b'{"enabled":true}', + method="POST", + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(enable) as response: + self.assertEqual(response.status, 204) + + with self.assertRaises(urllib.error.HTTPError) as failure: + urllib.request.urlopen(base_url + "/Items") + self.assertEqual(failure.exception.code, 503) + failure.exception.close() + self.assertEqual(_UpstreamHandler.requests, []) + + disable = urllib.request.Request( + base_url + "/__maestro/offline", + data=b'{"enabled":false}', + method="POST", + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(disable) as response: + self.assertEqual(response.status, 204) + with urllib.request.urlopen(base_url + "/Items") as response: + self.assertEqual(response.status, 200) + + events = [json.loads(line) for line in journal.read_text(encoding="utf-8").splitlines()] + self.assertEqual([event["kind"] for event in events], ["control", "offline", "control", "request"]) + finally: + self._stop_proxy(proxy, thread) + def test_music_fault_does_not_affect_other_requests(self) -> None: proxy, thread, base_url, _ = self._start_proxy("music-failure") try: @@ -135,6 +179,27 @@ class JellyfinProxyTests(unittest.TestCase): finally: self._stop_proxy(proxy, thread) + def test_records_events_before_response_write(self) -> None: + upstream_url = f"http://127.0.0.1:{self.upstream.server_port}" + cases = [ + ("fault", "recovery", "/Videos/movie/stream.mp4?Static=true", "fault", 503), + ("request", None, "/Items", "request", 200), + ] + + for name, fault, path, expected_kind, expected_status in cases: + with self.subTest(name=name): + journal = Path(self.temp_dir.name) / f"{name}.jsonl" + state = ProxyState(upstream_url, fault, journal) + handler = _ResponseFailureHandler(state, path) + + with self.assertRaises(BrokenPipeError): + JellyfinProxyHandler._proxy(handler) + + events = [json.loads(line) for line in journal.read_text(encoding="utf-8").splitlines()] + self.assertEqual(len(events), 1) + self.assertEqual(events[0]["kind"], expected_kind) + self.assertEqual(events[0]["status"], expected_status) + if __name__ == "__main__": unittest.main() diff --git a/scripts/test_run_maestro.py b/scripts/test_run_maestro.py index 501a5d16..8ff511a8 100755 --- a/scripts/test_run_maestro.py +++ b/scripts/test_run_maestro.py @@ -9,7 +9,7 @@ from pathlib import Path import subprocess import sys import unittest -from unittest.mock import patch +from unittest.mock import MagicMock, patch sys.path.insert(0, str(Path(__file__).resolve().parent)) @@ -94,6 +94,30 @@ class CommandTests(unittest.TestCase): ], ) + def test_offline_fault_exposes_host_proxy_control_url(self) -> None: + config = run_maestro.parse_config(["basic", "--fault", "offline", "--adb-reverse"], {}) + runner = run_maestro.MaestroRunner(config) + runner.host_jellyfin_url = "http://127.0.0.1:8097" + runner.device_service_port = 8097 + + command = runner.maestro_command() + + self.assertIn("JELLYFIN_URL=http://127.0.0.1:8097", command) + self.assertIn("JELLYFIN_CONTROL_URL=http://127.0.0.1:8097", command) + + def test_flutter_build_enables_stable_physical_device_controls(self) -> None: + self.assertEqual( + run_maestro.flutter_build_command(), + ( + "flutter", + "build", + "apk", + "--debug", + "--dart-define=PLEZY_MAESTRO_E2E=true", + ), + ) + + def test_explicit_device_url_wins_over_network_mode(self) -> None: config = run_maestro.parse_config( ["basic", "--adb-reverse", "--jellyfin-url", "http://device.test:9000"], @@ -134,6 +158,21 @@ class LifecycleTests(unittest.TestCase): self.assertEqual(run_command.call_count, 2) sleep.assert_called_once_with(5) + def test_health_wait_rejects_degraded_until_healthy(self) -> None: + runner = run_maestro.MaestroRunner(run_maestro.parse_config([], {})) + degraded = MagicMock() + degraded.__enter__.return_value.read.return_value = b"Degraded" + healthy = MagicMock() + healthy.__enter__.return_value.read.return_value = b" Healthy\n" + + with ( + patch.object(run_maestro.urllib.request, "urlopen", side_effect=[degraded, healthy]), + patch.object(run_maestro.time, "sleep") as sleep, + ): + runner._wait_for_health("http://jellyfin.test", attempts=2, interval=0.25, service="Jellyfin") + + sleep.assert_called_once_with(0.25) + class CiGroupTests(unittest.TestCase): def test_android_15_group_runs_every_suite_after_failure(self) -> None: @@ -142,12 +181,14 @@ class CiGroupTests(unittest.TestCase): with ( patch.object(run_maestro_ci.run_maestro, "main", side_effect=statuses) as run, + patch.object(run_maestro_ci, "run_android_15_instrumentation") as instrumentation, redirect_stdout(io.StringIO()), ): exit_status = run_maestro_ci.run_group("android-15") self.assertEqual(exit_status, 1) self.assertEqual(run.call_count, expected_runs) + instrumentation.assert_called_once_with() def test_group_recipes_are_valid_runner_invocations(self) -> None: for recipes in run_maestro_ci.GROUPS.values(): @@ -158,12 +199,14 @@ class CiGroupTests(unittest.TestCase): def test_group_stops_after_interruption(self) -> None: with ( patch.object(run_maestro_ci.run_maestro, "main", return_value=143) as run, + patch.object(run_maestro_ci, "run_android_15_instrumentation") as instrumentation, redirect_stdout(io.StringIO()), ): exit_status = run_maestro_ci.run_group("android-15") self.assertEqual(exit_status, 143) run.assert_called_once_with(("basic",)) + instrumentation.assert_called_once_with() if __name__ == "__main__": diff --git a/server/main_test.go b/server/main_test.go index ae2dfdce..0c5f75e0 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -472,6 +472,34 @@ func (c *testConn) recvNothing(within time.Duration) { } } +// recvUntilClosed consumes any frames already queued on the wire and requires +// a permanent terminal read error before the absolute deadline. +func (c *testConn) recvUntilClosed(within time.Duration) ([]serverMsg, error) { + c.t.Helper() + if err := c.conn.SetReadDeadline(time.Now().Add(within)); err != nil { + return nil, fmt.Errorf("set close-read deadline: %w", err) + } + + var messages []serverMsg + for { + messageType, data, err := c.conn.ReadMessage() + if err != nil { + if ne, ok := err.(net.Error); ok && ne.Timeout() { + return messages, fmt.Errorf("terminal closure not observed within %v: %w", within, err) + } + return messages, nil + } + if messageType != websocket.TextMessage { + return messages, fmt.Errorf("unexpected websocket message type %d before closure", messageType) + } + var message serverMsg + if err := json.Unmarshal(data, &message); err != nil { + return messages, fmt.Errorf("decode frame before closure %q: %w", data, err) + } + messages = append(messages, message) + } +} + // ====================================================================== // Unit tests — pure logic // ====================================================================== @@ -1261,9 +1289,8 @@ func TestCleanupDisconnectsPeersBeforeRemovingExpiredOccupiedRoom(t *testing.T) } for name, connection := range map[string]*testConn{"host": host, "guest": guest} { - connection.conn.SetReadDeadline(time.Now().Add(2 * time.Second)) - if _, _, err := connection.conn.ReadMessage(); err == nil { - t.Errorf("%s remained connected after occupied room removal", name) + if _, err := connection.recvUntilClosed(2 * time.Second); err != nil { + t.Errorf("%s did not reach terminal closure: %v", name, err) } } } diff --git a/test/screens/settings/external_player_screen_test.dart b/test/screens/settings/external_player_screen_test.dart index 171ff3a4..1365212f 100644 --- a/test/screens/settings/external_player_screen_test.dart +++ b/test/screens/settings/external_player_screen_test.dart @@ -22,7 +22,6 @@ void main() { tearDown(() { SettingsService.resetForTesting(); - resetSharedPreferencesForTest(); }); testWidgets('only custom players expose a focusable delete action', (tester) async { diff --git a/test/services/download_artwork_service_test.dart b/test/services/download_artwork_service_test.dart index 46b9ac6e..987699f5 100644 --- a/test/services/download_artwork_service_test.dart +++ b/test/services/download_artwork_service_test.dart @@ -39,18 +39,22 @@ class _DelayedCountingHttpClient extends http.BaseClient { void main() { late Directory tmpRoot; + late PathProviderPlatform previousPathProvider; setUp(() async { resetSharedPreferencesForTest(); SettingsService.resetForTesting(); DownloadStorageService.resetForTesting(); tmpRoot = await Directory.systemTemp.createTemp('download_artwork_service_test_'); + previousPathProvider = PathProviderPlatform.instance; PathProviderPlatform.instance = FakePathProvider(tmpRoot); }); tearDown(() async { DownloadStorageService.resetForTesting(); SettingsService.resetForTesting(); + PathProviderPlatform.instance = previousPathProvider; + expect(PathProviderPlatform.instance, same(previousPathProvider)); if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true); }); diff --git a/test/services/download_manager_service_test.dart b/test/services/download_manager_service_test.dart index 5d95abc7..a2256f28 100644 --- a/test/services/download_manager_service_test.dart +++ b/test/services/download_manager_service_test.dart @@ -214,10 +214,13 @@ void main() { SettingsService.resetForTesting(); DownloadStorageService.resetForTesting(); final tmpRoot = await Directory.systemTemp.createTemp('download_manager_artwork_repair_test_'); + final previousPathProvider = PathProviderPlatform.instance; PathProviderPlatform.instance = FakePathProvider(tmpRoot); addTearDown(() async { DownloadStorageService.resetForTesting(); SettingsService.resetForTesting(); + PathProviderPlatform.instance = previousPathProvider; + expect(PathProviderPlatform.instance, same(previousPathProvider)); if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true); }); @@ -308,10 +311,13 @@ void main() { SettingsService.resetForTesting(); DownloadStorageService.resetForTesting(); final tmpRoot = await Directory.systemTemp.createTemp('download_manager_delete_test_'); + final previousPathProvider = PathProviderPlatform.instance; PathProviderPlatform.instance = FakePathProvider(tmpRoot); addTearDown(() async { DownloadStorageService.resetForTesting(); SettingsService.resetForTesting(); + PathProviderPlatform.instance = previousPathProvider; + expect(PathProviderPlatform.instance, same(previousPathProvider)); if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true); }); @@ -606,6 +612,7 @@ Future<_DeletionResult> _runEpisodeDeletion({required bool saf, bool failVideoDe SettingsService.resetForTesting(); DownloadStorageService.resetForTesting(); final tmpRoot = await Directory.systemTemp.createTemp('download_manager_backend_delete_test_'); + final previousPathProvider = PathProviderPlatform.instance; PathProviderPlatform.instance = FakePathProvider(tmpRoot); final storage = saf ? DownloadStorageService.forTestingSaf('content://downloads') : DownloadStorageService.instance; @@ -710,6 +717,8 @@ Future<_DeletionResult> _runEpisodeDeletion({required bool saf, bool failVideoDe await db.close(); DownloadStorageService.resetForTesting(); SettingsService.resetForTesting(); + PathProviderPlatform.instance = previousPathProvider; + expect(PathProviderPlatform.instance, same(previousPathProvider)); if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true); } } @@ -719,6 +728,7 @@ Future<_ContainerDeletionResult> _runContainerDeletion({required MediaKind kind, SettingsService.resetForTesting(); DownloadStorageService.resetForTesting(); final tmpRoot = await Directory.systemTemp.createTemp('download_manager_container_delete_test_'); + final previousPathProvider = PathProviderPlatform.instance; PathProviderPlatform.instance = FakePathProvider(tmpRoot); final storage = saf ? DownloadStorageService.forTestingSaf('content://downloads') : DownloadStorageService.instance; @@ -796,6 +806,8 @@ Future<_ContainerDeletionResult> _runContainerDeletion({required MediaKind kind, await db.close(); DownloadStorageService.resetForTesting(); SettingsService.resetForTesting(); + PathProviderPlatform.instance = previousPathProvider; + expect(PathProviderPlatform.instance, same(previousPathProvider)); if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true); } } diff --git a/test/services/download_storage_service_test.dart b/test/services/download_storage_service_test.dart index b7149044..ca88eee5 100644 --- a/test/services/download_storage_service_test.dart +++ b/test/services/download_storage_service_test.dart @@ -16,18 +16,22 @@ import '../test_helpers/media_items.dart'; void main() { late Directory tmpRoot; + late PathProviderPlatform previousPathProvider; setUp(() async { resetSharedPreferencesForTest(); SettingsService.resetForTesting(); DownloadStorageService.resetForTesting(); tmpRoot = await Directory.systemTemp.createTemp('dss_test_'); + previousPathProvider = PathProviderPlatform.instance; PathProviderPlatform.instance = FakePathProvider(tmpRoot); }); tearDown(() async { DownloadStorageService.resetForTesting(); SettingsService.resetForTesting(); + PathProviderPlatform.instance = previousPathProvider; + expect(PathProviderPlatform.instance, same(previousPathProvider)); if (await tmpRoot.exists()) { await tmpRoot.delete(recursive: true); } diff --git a/test/services/episode_navigation_service_test.dart b/test/services/episode_navigation_service_test.dart index 37ac6b82..db657e4f 100644 --- a/test/services/episode_navigation_service_test.dart +++ b/test/services/episode_navigation_service_test.dart @@ -14,23 +14,6 @@ import 'package:plezy/services/multi_server_manager.dart'; import 'package:provider/provider.dart'; import '../test_helpers/media_items.dart'; -// NOTE on coverage scope: -// `EpisodeNavigationService` has two methods: -// -// 1. `loadAdjacentEpisodes` — pure-ish: reads PlaybackStateProvider, asks for -// next/prev episode, wraps the result. The interesting branch is the -// "no queue active" short-circuit, which we exercise without any client -// or network because PlaybackStateProvider can be constructed bare. -// -// 2. `navigateToEpisode` — performs full navigation through -// [navigateToVideoPlayer], which depends on a Navigator, a -// DownloadProvider, a MultiServerProvider, and the [SettingsService] -// singleton. Skipped: not unit-testable without recreating the entire -// app shell. -// -// We also cover the [AdjacentEpisodes] data class invariants since that's -// the public surface callers depend on. - MediaItem _meta(String id, {String? title}) => testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: title ?? 'Episode $id'); diff --git a/test/services/multi_server_manager_test.dart b/test/services/multi_server_manager_test.dart index 0adba63c..974d1e69 100644 --- a/test/services/multi_server_manager_test.dart +++ b/test/services/multi_server_manager_test.dart @@ -30,30 +30,13 @@ JellyfinConnection _jellyfinConnection(String userId) => testJellyfinConnection( JellyfinClient _jellyfinClient(String userId) => testJellyfinClient(connection: _jellyfinConnection(userId)); -// NOTE on coverage scope: -// [MultiServerManager.addServer] / `connectToAllServers` / `_createClientForServer` -// all instantiate a real `PlexClient` via `findBestWorkingConnection`, which -// performs live HTTP calls to a Plex Media Server. The manager does NOT expose -// a fake `PlexClient` factory, so per the task brief we don't fake the network -// here. -// -// The tests below cover the orchestration logic that DOESN'T require a network: -// - construction & initial state -// - `removeServer` (pure local-map mutation) -// - `updateServerStatus` + status-stream emissions -// - `disconnectAll` / `dispose` lifecycle (no connectivity sub started, so -// this verifies the no-op path for the subscription cancel) -// -// The Jellyfin exhaustion path IS covered ('endpoint exhaustion verification' -// group): the health-probe confirmation, offline flip + reconnection, and the -// debounce-driven retry loop, via the registered fake Jellyfin client. -// -// What is NOT covered here (would need a fake PlexClient factory): -// - `addServer` success path -// - `connectToAllServers` outcome map -// - `checkServerHealth` health-probe sweep -// - `_reoptimizeServer` endpoint promotion -// - `startNetworkMonitoring` connectivity-listener path +// Coverage includes status and lifecycle changes, endpoint exhaustion, +// in-place Plex token refresh, Jellyfin reuse/update, and selected +// registered-Jellyfin `checkServerHealth` outcomes. First-time +// `addPlexAccount` and `refreshTokensForProfile` fallback construction through +// `_createClientForServer`, Plex and mixed-client health/coalescing, +// `_reoptimizeServer`, and `_startNetworkMonitoring` subscription/debounce +// behavior remain outside this suite. void main() { setUp(resetSharedPreferencesForTest); diff --git a/test/services/offline_watch_sync_service_test.dart b/test/services/offline_watch_sync_service_test.dart index 1ee588bf..75fc56fd 100644 --- a/test/services/offline_watch_sync_service_test.dart +++ b/test/services/offline_watch_sync_service_test.dart @@ -23,27 +23,13 @@ import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; -// NOTE on coverage scope: -// The actual sync-to-server path (`syncPendingItems`, `syncWatchStatesFromServer`, -// `_performBidirectionalSync`) all reach into a real `PlexClient` via the -// injected `MultiServerManager`. Per the task brief we do NOT exercise those -// paths here — they require either a fake `PlexClient` factory or live HTTP. -// -// What IS covered: -// - Initial state on a fresh service. -// - `queueMarkWatched` / `queueMarkUnwatched` — local DB persistence. -// - `getLocalWatchStatus` / `getLocalViewOffset` — local resolution. -// - `getPendingSyncCount` — DB-side count. -// - `clearAll` — local wipe. -// - `dispose` — listener cleanup on the offline-mode source. -// - Connectivity listener attachment via `startConnectivityMonitoring`. -// -// What is NOT covered (would need a fake PlexClient factory): -// - `_performBidirectionalSync` (online path) -// - `syncPendingItems` outcome map -// - `syncWatchStatesFromServer` cache-write logic -// - `getWatchedThreshold`'s "online client preference" branch — only the -// SettingsService cached + default branches are testable here. +// Direct `syncPendingItems` coverage exercises retry retention, Plex/Jellyfin +// progress replay, profile interruption, and scoped Jellyfin routing. Direct +// `syncWatchStatesFromServer` coverage exercises active-profile and +// active-scope routing plus selected watched outcomes. Trigger coalescing and +// throttle sequencing inside `_performBidirectionalSync`, direct cache-row and +// refresh-callback assertions, and non-default watched-threshold sources remain +// outside this suite. /// Minimal [OfflineModeSource] that lets tests flip the offline flag and /// observe `addListener`/`removeListener` traffic via the protected diff --git a/test/services/playback_initialization_offline_cache_test.dart b/test/services/playback_initialization_offline_cache_test.dart index 24d87d83..0af80d82 100644 --- a/test/services/playback_initialization_offline_cache_test.dart +++ b/test/services/playback_initialization_offline_cache_test.dart @@ -30,12 +30,14 @@ void main() { late AppDatabase db; late Directory tmpRoot; + late PathProviderPlatform previousPathProvider; setUp(() async { resetSharedPreferencesForTest(); SettingsService.resetForTesting(); DownloadStorageService.resetForTesting(); tmpRoot = await Directory.systemTemp.createTemp('playback_init_test_'); + previousPathProvider = PathProviderPlatform.instance; PathProviderPlatform.instance = FakePathProvider(tmpRoot); db = AppDatabase.forTesting(NativeDatabase.memory()); PlexApiCache.initialize(db); @@ -46,6 +48,8 @@ void main() { await db.close(); DownloadStorageService.resetForTesting(); SettingsService.resetForTesting(); + PathProviderPlatform.instance = previousPathProvider; + expect(PathProviderPlatform.instance, same(previousPathProvider)); if (await tmpRoot.exists()) { await tmpRoot.delete(recursive: true); } diff --git a/test/test_helpers/prefs.dart b/test/test_helpers/prefs.dart index 2e43d461..b53b9c95 100644 --- a/test/test_helpers/prefs.dart +++ b/test/test_helpers/prefs.dart @@ -1,12 +1,23 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/services/base_shared_preferences_service.dart'; +import 'package:plezy/services/settings_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; /// Reset shared-prefs platform mocks AND the cached singleton instances. /// Call from `setUp` so each test starts with a clean slate. void resetSharedPreferencesForTest({Map initialAsync = const {}}) { + final previousLegacyPlatform = SharedPreferencesStorePlatform.instance; + final previousAsyncPlatform = SharedPreferencesAsyncPlatform.instance; + addTearDown(() { + SharedPreferencesStorePlatform.instance = previousLegacyPlatform; + SharedPreferencesAsyncPlatform.instance = previousAsyncPlatform; + SharedPreferences.resetStatic(); + SettingsService.resetForTesting(); + BaseSharedPreferencesService.resetForTesting(); + }); TestWidgetsFlutterBinding.ensureInitialized(); SharedPreferences.setMockInitialValues({}); SharedPreferencesAsyncPlatform.instance = initialAsync.isEmpty diff --git a/test/test_helpers/prefs_test.dart b/test/test_helpers/prefs_test.dart new file mode 100644 index 00000000..49f30e8b --- /dev/null +++ b/test/test_helpers/prefs_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/base_shared_preferences_service.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; + +import 'prefs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late SharedPreferencesStorePlatform originalLegacyPlatform; + late SharedPreferencesAsyncPlatform? originalAsyncPlatform; + late SharedPreferencesStorePlatform sentinelLegacyPlatform; + late SharedPreferencesAsyncPlatform sentinelAsyncPlatform; + + setUp(() { + originalLegacyPlatform = SharedPreferencesStorePlatform.instance; + originalAsyncPlatform = SharedPreferencesAsyncPlatform.instance; + sentinelLegacyPlatform = InMemorySharedPreferencesStore.withData({'flutter.restored_legacy': true}); + sentinelAsyncPlatform = InMemorySharedPreferencesAsync.withData({'restored_async': true}); + SharedPreferencesStorePlatform.instance = sentinelLegacyPlatform; + SharedPreferencesAsyncPlatform.instance = sentinelAsyncPlatform; + SharedPreferences.resetStatic(); + SettingsService.resetForTesting(); + BaseSharedPreferencesService.resetForTesting(); + }); + + tearDown(() async { + try { + expect(SharedPreferencesStorePlatform.instance, same(sentinelLegacyPlatform)); + expect(SharedPreferencesAsyncPlatform.instance, same(sentinelAsyncPlatform)); + expect((await SharedPreferences.getInstance()).getBool('restored_legacy'), isTrue); + expect(await SharedPreferencesAsync().getBool('restored_async'), isTrue); + final restoredSettings = await SettingsService.getInstance(); + expect(restoredSettings.prefs.getBool('temporary_value'), isNull); + } finally { + SharedPreferencesStorePlatform.instance = originalLegacyPlatform; + SharedPreferencesAsyncPlatform.instance = originalAsyncPlatform; + SharedPreferences.resetStatic(); + SettingsService.resetForTesting(); + BaseSharedPreferencesService.resetForTesting(); + } + }); + + test('restores both platform singletons and invalidates cached services', () async { + resetSharedPreferencesForTest(); + + final temporarySettings = await SettingsService.getInstance(); + await temporarySettings.prefs.setBool('temporary_value', true); + expect(temporarySettings.prefs.getBool('temporary_value'), isTrue); + expect(SharedPreferencesStorePlatform.instance, isNot(same(sentinelLegacyPlatform))); + expect(SharedPreferencesAsyncPlatform.instance, isNot(same(sentinelAsyncPlatform))); + }); + + test('nested resets restore the immediately preceding platform pair', () { + resetSharedPreferencesForTest(initialAsync: const {'first': true}); + final firstLegacyPlatform = SharedPreferencesStorePlatform.instance; + final firstAsyncPlatform = SharedPreferencesAsyncPlatform.instance; + + addTearDown(() { + expect(SharedPreferencesStorePlatform.instance, same(firstLegacyPlatform)); + expect(SharedPreferencesAsyncPlatform.instance, same(firstAsyncPlatform)); + }); + + resetSharedPreferencesForTest(initialAsync: const {'second': true}); + expect(SharedPreferencesStorePlatform.instance, isNot(same(firstLegacyPlatform))); + expect(SharedPreferencesAsyncPlatform.instance, isNot(same(firstAsyncPlatform))); + }); +} diff --git a/test/utils/endpoint_race_test.dart b/test/utils/endpoint_race_test.dart index 73635356..059812b2 100644 --- a/test/utils/endpoint_race_test.dart +++ b/test/utils/endpoint_race_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/utils/endpoint_race.dart'; @@ -30,145 +31,220 @@ void main() { ); } - Future<_Result> resultAfter(String url, Duration delay, {required bool ok}) async { - await Future.delayed(delay); - return (url: url, ok: ok); - } + test('healthy cached endpoint wins within the head start without racing', () { + fakeAsync((async) { + final probeCounts = {}; + final selections = >[]; + late Completer<_Result> cachedGate; - test('healthy cached endpoint wins within the head start without racing', () async { - final probeCounts = {}; - final selections = await race( - candidates: ['a', 'cached'], - preferred: 'cached', - probe: (url) { - probeCounts[url] = (probeCounts[url] ?? 0) + 1; - return resultAfter(url, const Duration(milliseconds: 10), ok: true); - }, - ).toList(); + race( + candidates: ['a', 'cached'], + preferred: 'cached', + probe: (url) { + probeCounts[url] = (probeCounts[url] ?? 0) + 1; + final gate = Completer<_Result>(); + if (url == 'cached') cachedGate = gate; + return gate.future; + }, + ).listen(selections.add); + async.flushMicrotasks(); - expect(selections.first.phase, EndpointRacePhase.first); - expect(selections.first.candidate, 'cached'); - expect(selections.first.fromPreferred, isTrue); - expect(probeCounts['cached'], 1); - // The race never started; only the phase-2 measure touches other URLs. - expect(probeCounts.containsKey('a'), isFalse); + expect(probeCounts, {'cached': 1}); + cachedGate.complete((url: 'cached', ok: true)); + async.flushMicrotasks(); + + expect(selections.first.phase, EndpointRacePhase.first); + expect(selections.first.candidate, 'cached'); + expect(selections.first.fromPreferred, isTrue); + expect(probeCounts.containsKey('a'), isFalse); + + async.elapse(headStart); + async.flushMicrotasks(); + }); }); - test('stale-slow cached endpoint overlaps the race instead of serially blocking it', () async { - final probeCounts = {}; - final stopwatch = Stopwatch()..start(); - final firstTimes = []; - final selections = >[]; - await for (final selection in race( - candidates: ['fast', 'cached'], - preferred: 'cached', - probe: (url) { - probeCounts[url] = (probeCounts[url] ?? 0) + 1; - return resultAfter( - url, - url == 'cached' ? const Duration(milliseconds: 250) : const Duration(milliseconds: 10), - ok: url != 'cached', - ); - }, - )) { - selections.add(selection); - firstTimes.add(stopwatch.elapsedMilliseconds); - } + test('stale-slow cached endpoint overlaps the race instead of serially blocking it', () { + fakeAsync((async) { + final probeCounts = {}; + final selections = >[]; + late Completer<_Result> cachedGate; + late Completer<_Result> fastGate; - expect(selections.first.candidate, 'fast'); - expect(selections.first.fromPreferred, isFalse); - // Emitted shortly after the head start — not after the cached probe's - // full budget (the pre-change serial behavior). - expect(firstTimes.first, lessThan(200)); - // The pending cached probe was merged into the race, not re-fired. - expect(probeCounts['cached'], 1); + race( + candidates: ['fast', 'cached'], + preferred: 'cached', + probe: (url) { + probeCounts[url] = (probeCounts[url] ?? 0) + 1; + final gate = Completer<_Result>(); + if (url == 'cached') { + cachedGate = gate; + } else { + fastGate = gate; + } + return gate.future; + }, + ).listen(selections.add); + async.flushMicrotasks(); - // Let the still-pending cached probe finish inside the test body. - await Future.delayed(const Duration(milliseconds: 300)); + expect(probeCounts, {'cached': 1}); + async.elapse(headStart - const Duration(milliseconds: 1)); + async.flushMicrotasks(); + expect(probeCounts, {'cached': 1}); + + async.elapse(const Duration(milliseconds: 1)); + async.flushMicrotasks(); + expect(probeCounts, {'cached': 1, 'fast': 1}); + + fastGate.complete((url: 'fast', ok: true)); + async.flushMicrotasks(); + expect(selections.first.phase, EndpointRacePhase.first); + expect(selections.first.candidate, 'fast'); + expect(selections.first.fromPreferred, isFalse); + expect(probeCounts['cached'], 1); + + cachedGate.complete((url: 'cached', ok: false)); + async.flushMicrotasks(); + }); }); - test('cached endpoint that answers after the head start still wins when first', () async { - final probeCounts = {}; - final selections = await race( - candidates: ['slow', 'cached'], - preferred: 'cached', - probe: (url) { - probeCounts[url] = (probeCounts[url] ?? 0) + 1; - return resultAfter( - url, - url == 'cached' ? const Duration(milliseconds: 120) : const Duration(milliseconds: 350), - ok: true, - ); - }, - ).toList(); + test('cached endpoint that answers after the head start still wins when first', () { + fakeAsync((async) { + final probeCounts = {}; + final selections = >[]; + late Completer<_Result> cachedGate; + late Completer<_Result> slowGate; - expect(selections.first.candidate, 'cached'); - expect(selections.first.fromPreferred, isTrue); - expect(probeCounts['cached'], 1); + race( + candidates: ['slow', 'cached'], + preferred: 'cached', + probe: (url) { + probeCounts[url] = (probeCounts[url] ?? 0) + 1; + final gate = Completer<_Result>(); + if (url == 'cached') { + cachedGate = gate; + } else { + slowGate = gate; + } + return gate.future; + }, + ).listen(selections.add); + async.flushMicrotasks(); - await Future.delayed(const Duration(milliseconds: 400)); + expect(probeCounts, {'cached': 1}); + async.elapse(headStart); + async.flushMicrotasks(); + expect(probeCounts, {'cached': 1, 'slow': 1}); + + cachedGate.complete((url: 'cached', ok: true)); + async.flushMicrotasks(); + expect(selections.first.candidate, 'cached'); + expect(selections.first.fromPreferred, isTrue); + expect(probeCounts['cached'], 1); + + slowGate.complete((url: 'slow', ok: true)); + async.flushMicrotasks(); + }); }); - test('cached endpoint failing within the head start falls back to a fresh race', () async { - final probeCounts = {}; - final selections = await race( - candidates: ['cached', 'alt'], - preferred: 'cached', - probe: (url) { - probeCounts[url] = (probeCounts[url] ?? 0) + 1; - return resultAfter(url, const Duration(milliseconds: 10), ok: url == 'alt'); - }, - ).toList(); + test('cached endpoint failing within the head start falls back to a fresh race', () { + fakeAsync((async) { + final probeCounts = {}; + final selections = >[]; + final cachedGates = >[]; + late Completer<_Result> altGate; - expect(selections.first.candidate, 'alt'); - expect(selections.first.fromPreferred, isFalse); - // Fast-fail keeps today's semantics: the cached URL re-races as a - // normal candidate (one probe up front, one inside the race). - expect(probeCounts['cached'], 2); + race( + candidates: ['cached', 'alt'], + preferred: 'cached', + probe: (url) { + probeCounts[url] = (probeCounts[url] ?? 0) + 1; + final gate = Completer<_Result>(); + if (url == 'cached') { + cachedGates.add(gate); + } else { + altGate = gate; + } + return gate.future; + }, + ).listen(selections.add); + async.flushMicrotasks(); + + cachedGates.single.complete((url: 'cached', ok: false)); + async.flushMicrotasks(); + expect(probeCounts, {'cached': 2, 'alt': 1}); + + cachedGates.last.complete((url: 'cached', ok: false)); + altGate.complete((url: 'alt', ok: true)); + async.flushMicrotasks(); + + expect(selections.first.candidate, 'alt'); + expect(selections.first.fromPreferred, isFalse); + expect(probeCounts['cached'], 2); + + async.elapse(headStart); + async.flushMicrotasks(); + }); }); - test('preferred URL not among candidates skips the cached probe entirely', () async { - final probeCounts = {}; - final selections = await race( - candidates: ['a', 'b'], - preferred: 'custom-url', - probe: (url) { - probeCounts[url] = (probeCounts[url] ?? 0) + 1; - return resultAfter(url, const Duration(milliseconds: 10), ok: url == 'a'); - }, - ).toList(); + test('preferred URL not among candidates skips the cached probe entirely', () { + fakeAsync((async) { + final probeCounts = {}; + final selections = >[]; + final gates = >{}; - expect(selections.first.candidate, 'a'); - expect(selections.first.fromPreferred, isFalse); - expect(probeCounts.containsKey('custom-url'), isFalse); + race( + candidates: ['a', 'b'], + preferred: 'custom-url', + probe: (url) { + probeCounts[url] = (probeCounts[url] ?? 0) + 1; + return (gates[url] = Completer<_Result>()).future; + }, + ).listen(selections.add); + async.flushMicrotasks(); + + expect(probeCounts.containsKey('custom-url'), isFalse); + gates['a']!.complete((url: 'a', ok: true)); + gates['b']!.complete((url: 'b', ok: false)); + async.flushMicrotasks(); + + expect(selections.first.candidate, 'a'); + expect(selections.first.fromPreferred, isFalse); + }); }); test('emits nothing when every candidate fails', () async { final selections = await race( candidates: ['a', 'b'], preferred: 'a', - probe: (url) => resultAfter(url, const Duration(milliseconds: 10), ok: false), + probe: (url) async => (url: url, ok: false), ).toList(); expect(selections, isEmpty); }); - test('phase 2 still promotes the selector-best endpoint', () async { - final selections = await race( - candidates: ['quick', 'better'], - probe: (url) => resultAfter( - url, - url == 'quick' ? const Duration(milliseconds: 10) : const Duration(milliseconds: 80), - ok: true, - ), - measure: (url) async => (url: url, ok: true), - selectBest: (results) => 'better', - ).toList(); + test('phase 2 still promotes the selector-best endpoint', () { + fakeAsync((async) { + final selections = >[]; + final gates = >{}; - expect(selections, hasLength(2)); - expect(selections.first.phase, EndpointRacePhase.first); - expect(selections.first.candidate, 'quick'); - expect(selections.last.phase, EndpointRacePhase.best); - expect(selections.last.candidate, 'better'); + race( + candidates: ['quick', 'better'], + probe: (url) => (gates[url] = Completer<_Result>()).future, + measure: (url) async => (url: url, ok: true), + selectBest: (results) => 'better', + ).listen(selections.add); + async.flushMicrotasks(); + + gates['quick']!.complete((url: 'quick', ok: true)); + async.flushMicrotasks(); + gates['better']!.complete((url: 'better', ok: true)); + async.flushMicrotasks(); + + expect(selections, hasLength(2)); + expect(selections.first.phase, EndpointRacePhase.first); + expect(selections.first.candidate, 'quick'); + expect(selections.last.phase, EndpointRacePhase.best); + expect(selections.last.candidate, 'better'); + }); }); }