test: stabilize deterministic integration coverage
This commit is contained in:
@@ -77,7 +77,7 @@ jobs:
|
|||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
- name: Build debug APK
|
- name: Build debug APK
|
||||||
run: flutter build apk --debug
|
run: flutter build apk --debug --dart-define=PLEZY_MAESTRO_E2E=true
|
||||||
|
|
||||||
- name: Cache Maestro CLI
|
- name: Cache Maestro CLI
|
||||||
id: maestro-cache
|
id: maestro-cache
|
||||||
@@ -272,6 +272,7 @@ jobs:
|
|||||||
name: maestro-android-diagnostics
|
name: maestro-android-diagnostics
|
||||||
path: |
|
path: |
|
||||||
build/maestro
|
build/maestro
|
||||||
|
build/maestro-offline
|
||||||
build/maestro-real-jellyfin
|
build/maestro-real-jellyfin
|
||||||
build/maestro-media
|
build/maestro-media
|
||||||
build/maestro-tv
|
build/maestro-tv
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ executionOrder:
|
|||||||
- Open empty downloads state
|
- Open empty downloads state
|
||||||
- Manage profiles and open settings
|
- Manage profiles and open settings
|
||||||
- Logout returns to authentication
|
- Logout returns to authentication
|
||||||
|
- Download and play a movie from Downloads
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ tags:
|
|||||||
timeout: 10000
|
timeout: 10000
|
||||||
- assertVisible: "(?s)Server URLs.*"
|
- assertVisible: "(?s)Server URLs.*"
|
||||||
- assertVisible: "Find server"
|
- assertVisible: "Find server"
|
||||||
- hideKeyboard
|
- runFlow:
|
||||||
- back
|
when:
|
||||||
|
platform: iOS
|
||||||
|
commands:
|
||||||
|
- tapOn: "Back"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
platform: Android
|
||||||
|
commands:
|
||||||
|
- hideKeyboard:
|
||||||
|
optional: true
|
||||||
|
- back
|
||||||
- assertVisible: "Sign in with Plex"
|
- assertVisible: "Sign in with Plex"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ tags:
|
|||||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||||
- assertVisible: "(?s)^Home.*"
|
- assertVisible: "(?s)^Home.*"
|
||||||
- assertVisible: "(?s)^Recently Added.*"
|
- 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)^Libraries.*"
|
||||||
- assertVisible: "(?s)^Search.*"
|
- assertVisible: "(?s)^Search.*"
|
||||||
- assertVisible: "(?s)^Downloads.*"
|
- assertVisible: "(?s)^Downloads.*"
|
||||||
|
|||||||
@@ -6,17 +6,28 @@ tags:
|
|||||||
---
|
---
|
||||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||||
- tapOn: "(?s)^Libraries.*"
|
- tapOn: "(?s)^Libraries.*"
|
||||||
|
- tapOn: "Browse"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Maestro Movies"
|
visible: "(?s).*Maestro Movies.*"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s).*Zulu Zone 4$"
|
visible: "(?s).*Alpha Archive.*"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
- tapOn: "(?s).*Zulu Zone 4$"
|
- tapOn: "(?s).*Alpha Archive.*"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Overview"
|
visible: "Overview"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
- assertVisible: "A deterministic title for TV alphabet focus coverage."
|
- assertVisible: "A deterministic title for TV alphabet focus coverage."
|
||||||
- assertVisible: "Play"
|
- assertVisible: "Play"
|
||||||
- back
|
- runFlow:
|
||||||
- assertVisible: "Maestro Movies"
|
when:
|
||||||
|
platform: iOS
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
point: "6%, 9%"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
platform: Android
|
||||||
|
commands:
|
||||||
|
- back
|
||||||
|
- assertVisible: "(?s).*Maestro Movies.*"
|
||||||
|
|||||||
@@ -11,11 +11,20 @@ tags:
|
|||||||
timeout: 10000
|
timeout: 10000
|
||||||
- tapOn: "Search movies, shows, music..."
|
- tapOn: "Search movies, shows, music..."
|
||||||
- inputText: "Maestro"
|
- inputText: "Maestro"
|
||||||
- hideKeyboard
|
- runFlow:
|
||||||
|
when:
|
||||||
|
platform: iOS
|
||||||
|
commands:
|
||||||
|
- pressKey: ENTER
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
platform: Android
|
||||||
|
commands:
|
||||||
|
- hideKeyboard
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Maestro Movie"
|
visible: "(?s).*Maestro Movie.*"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
- tapOn: "Maestro Movie"
|
- tapOn: "(?s).*Maestro Movie.*"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Overview"
|
visible: "Overview"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
|
|||||||
@@ -6,24 +6,52 @@ tags:
|
|||||||
---
|
---
|
||||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||||
- tapOn: "(?s)^Libraries.*"
|
- tapOn: "(?s)^Libraries.*"
|
||||||
|
- tapOn: "Browse"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s).*Zulu Zone 4$"
|
visible: "(?s).*Alpha Archive.*"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
- tapOn: "(?s).*Zulu Zone 4$"
|
- tapOn: "(?s).*Alpha Archive.*"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Play"
|
visible: "Play"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
- tapOn:
|
- tapOn:
|
||||||
text: "Play"
|
text: "Play"
|
||||||
waitToSettleTimeoutMs: 1000
|
waitToSettleTimeoutMs: 1000
|
||||||
- extendedWaitUntil:
|
- runFlow:
|
||||||
visible: "Pause"
|
when:
|
||||||
timeout: 20000
|
platform: iOS
|
||||||
- back
|
commands:
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
notVisible: "Pause"
|
visible: "Video timeline"
|
||||||
timeout: 10000
|
timeout: 20000
|
||||||
- back
|
- extendedWaitUntil:
|
||||||
- extendedWaitUntil:
|
visible: "(?s).*Alpha Archive.*\\n0min\\n0:(?:0[1-9]|[1-5][0-9])\\n-.*"
|
||||||
visible: "Overview"
|
timeout: 10000
|
||||||
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
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ tags:
|
|||||||
- settings
|
- settings
|
||||||
---
|
---
|
||||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||||
- tapOn: "M"
|
- tapOn: "(?s)^M(?:.*Profiles.*)?$"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
text: "Profiles"
|
text: "Profiles"
|
||||||
above:
|
above:
|
||||||
@@ -21,16 +21,44 @@ tags:
|
|||||||
timeout: 10000
|
timeout: 10000
|
||||||
- tapOn: "e.g. Guests, Kids, Family Room"
|
- tapOn: "e.g. Guests, Kids, Family Room"
|
||||||
- inputText: "E2E Guest"
|
- inputText: "E2E Guest"
|
||||||
- hideKeyboard
|
- runFlow:
|
||||||
|
when:
|
||||||
|
platform: iOS
|
||||||
|
commands:
|
||||||
|
- pressKey: ENTER
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
platform: Android
|
||||||
|
commands:
|
||||||
|
- hideKeyboard
|
||||||
- tapOn: "Continue"
|
- tapOn: "Continue"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Add to E2E Guest"
|
visible: "Add to E2E Guest"
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
- assertVisible: "(?s)^Sign in with Plex.*"
|
- assertVisible: "(?s)^Sign in with Plex.*"
|
||||||
- assertVisible: "(?s)^Connect to Jellyfin.*"
|
- 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.*"
|
- assertVisible: "(?s).*E2E Guest.*"
|
||||||
- back
|
- runFlow:
|
||||||
|
when:
|
||||||
|
platform: iOS
|
||||||
|
commands:
|
||||||
|
- tapOn: "Back"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
platform: Android
|
||||||
|
commands:
|
||||||
|
- back
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s)^Home.*"
|
visible: "(?s)^Home.*"
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
@@ -41,7 +69,7 @@ tags:
|
|||||||
while:
|
while:
|
||||||
notVisible: "Settings"
|
notVisible: "Settings"
|
||||||
commands:
|
commands:
|
||||||
- tapOn: "M"
|
- tapOn: "(?s)^M(?:.*Profiles.*)?$"
|
||||||
- waitForAnimationToEnd:
|
- waitForAnimationToEnd:
|
||||||
timeout: 3000
|
timeout: 3000
|
||||||
- tapOn:
|
- tapOn:
|
||||||
@@ -53,10 +81,7 @@ tags:
|
|||||||
timeout: 10000
|
timeout: 10000
|
||||||
- assertVisible: "(?s)^Video Playback.*"
|
- assertVisible: "(?s)^Video Playback.*"
|
||||||
- assertVisible: "(?s)^Connections.*"
|
- assertVisible: "(?s)^Connections.*"
|
||||||
- tapOn: "(?s)^Appearance.*"
|
- runFlow:
|
||||||
- extendedWaitUntil:
|
when:
|
||||||
visible: "(?s)^Theme.*"
|
platform: Android
|
||||||
timeout: 10000
|
file: ../subflows/settings_deep_checks.yaml
|
||||||
- assertVisible: "(?s).*Library Density.*"
|
|
||||||
- back
|
|
||||||
- assertVisible: "(?s)^Appearance.*"
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ tags:
|
|||||||
- auth
|
- auth
|
||||||
---
|
---
|
||||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||||
- tapOn: "M"
|
- tapOn: "(?s)^M(?:.*Profiles.*)?$"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
text: "Logout"
|
text: "Logout"
|
||||||
below:
|
below:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -13,11 +13,13 @@ tags:
|
|||||||
- runFlow:
|
- runFlow:
|
||||||
file: ../subflows/switch_codec_tracks.yaml
|
file: ../subflows/switch_codec_tracks.yaml
|
||||||
env:
|
env:
|
||||||
AUDIO_TRACK: "(?is)^English.*(?:AC3|TrueHD|Surround|5\\.1|7\\.1).*"
|
AUDIO_TRACK: "(?is)^English\\s+[^,]*(?:AC3|TrueHD|Surround|5\\.1|7\\.1)[^,]*$"
|
||||||
SUBTITLE_TRACK: "(?is)^Spanish.*PGS.*"
|
SUBTITLE_TRACK: "(?is)^Spanish\\s+[^,]*(?:PGS|APPLICATION/PGS)[^,]*$"
|
||||||
- repeat:
|
- back
|
||||||
times: 2
|
- waitForAnimationToEnd:
|
||||||
while:
|
timeout: 3000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
notVisible: "Overview"
|
notVisible: "Overview"
|
||||||
commands:
|
commands:
|
||||||
- back
|
- back
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ tags:
|
|||||||
- runFlow:
|
- runFlow:
|
||||||
file: ../subflows/switch_codec_tracks.yaml
|
file: ../subflows/switch_codec_tracks.yaml
|
||||||
env:
|
env:
|
||||||
AUDIO_TRACK: "(?is)^German.*(?:E-AC3|Dolby Digital Plus|Surround|5\\.1).*"
|
AUDIO_TRACK: "(?is)^German\\s+[^,]*(?:E-AC3|Dolby Digital Plus|Surround|5\\.1)[^,]*$"
|
||||||
SUBTITLE_TRACK: "(?is)^German.*(?:SRT|APPLICATION/X-SUBRIP).*"
|
SUBTITLE_TRACK: "(?is)^German\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
|
||||||
- repeat:
|
- back
|
||||||
times: 2
|
- waitForAnimationToEnd:
|
||||||
while:
|
timeout: 3000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
notVisible: "Overview"
|
notVisible: "Overview"
|
||||||
commands:
|
commands:
|
||||||
- back
|
- back
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ tags:
|
|||||||
- runFlow:
|
- runFlow:
|
||||||
file: ../subflows/switch_codec_tracks.yaml
|
file: ../subflows/switch_codec_tracks.yaml
|
||||||
env:
|
env:
|
||||||
AUDIO_TRACK: "(?is)^English.*(?:Opus|Stereo).*"
|
AUDIO_TRACK: "(?is)^English\\s+[^,]*(?:Opus|Stereo)[^,]*$"
|
||||||
SUBTITLE_TRACK: "(?is)^English.*(?:ASS|TEXT/X-SSA).*"
|
SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:ASS|TEXT/X-SSA)[^,]*$"
|
||||||
- repeat:
|
- back
|
||||||
times: 2
|
- waitForAnimationToEnd:
|
||||||
while:
|
timeout: 3000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
notVisible: "Overview"
|
notVisible: "Overview"
|
||||||
commands:
|
commands:
|
||||||
- back
|
- back
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ tags:
|
|||||||
- runFlow:
|
- runFlow:
|
||||||
file: ../subflows/switch_codec_tracks.yaml
|
file: ../subflows/switch_codec_tracks.yaml
|
||||||
env:
|
env:
|
||||||
AUDIO_TRACK: "(?is)^Japanese.*(?:DTS-HD MA|DTS|Surround|5\\.1).*"
|
AUDIO_TRACK: "(?is)^Japanese\\s+[^,]*(?:DTS-HD MA|DTS|Surround|5\\.1)[^,]*$"
|
||||||
SUBTITLE_TRACK: "(?is)^English.*(?:ASS|TEXT/X-SSA).*"
|
SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:ASS|TEXT/X-SSA)[^,]*$"
|
||||||
- repeat:
|
- back
|
||||||
times: 2
|
- waitForAnimationToEnd:
|
||||||
while:
|
timeout: 3000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
notVisible: "Overview"
|
notVisible: "Overview"
|
||||||
commands:
|
commands:
|
||||||
- back
|
- back
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ tags:
|
|||||||
- runFlow:
|
- runFlow:
|
||||||
file: ../subflows/switch_codec_tracks_deep.yaml
|
file: ../subflows/switch_codec_tracks_deep.yaml
|
||||||
env:
|
env:
|
||||||
AUDIO_TRACK: "(?is)^English.*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1).*"
|
AUDIO_TRACK: "(?is)^English\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
|
||||||
SUBTITLE_TRACK: "(?is)^English.*(?:SRT|APPLICATION/X-SUBRIP).*"
|
SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
|
||||||
SHEET_MARKER: "(?is)^Japanese.*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1).*"
|
SHEET_MARKER: "(?is)^Japanese\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
|
||||||
- repeat:
|
- back
|
||||||
times: 2
|
- waitForAnimationToEnd:
|
||||||
while:
|
timeout: 3000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
notVisible: "Overview"
|
notVisible: "Overview"
|
||||||
commands:
|
commands:
|
||||||
- back
|
- back
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ tags:
|
|||||||
- runFlow:
|
- runFlow:
|
||||||
file: ../subflows/switch_codec_tracks.yaml
|
file: ../subflows/switch_codec_tracks.yaml
|
||||||
env:
|
env:
|
||||||
AUDIO_TRACK: "(?is)^Japanese.*(?:FLAC|Surround|5\\.1).*"
|
AUDIO_TRACK: "(?is)^Japanese\\s+[^,]*(?:FLAC|Surround|5\\.1)[^,]*$"
|
||||||
SUBTITLE_TRACK: "(?is)^English.*(?:ASS|TEXT/X-SSA).*"
|
SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:ASS|TEXT/X-SSA)[^,]*$"
|
||||||
- repeat:
|
- back
|
||||||
times: 2
|
- waitForAnimationToEnd:
|
||||||
while:
|
timeout: 3000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
notVisible: "Overview"
|
notVisible: "Overview"
|
||||||
commands:
|
commands:
|
||||||
- back
|
- back
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ tags:
|
|||||||
- inputText: "Codec DV UHD"
|
- inputText: "Codec DV UHD"
|
||||||
- hideKeyboard
|
- hideKeyboard
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s).*Codec DV UHD TrueHD PGS$"
|
visible: "(?s).*Codec DV UHD TrueHD PGS.*"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
- tapOn: "(?s).*Codec DV UHD TrueHD PGS$"
|
- tapOn: "(?s).*Codec DV UHD TrueHD PGS.*"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Overview"
|
visible: "Overview"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ tags:
|
|||||||
- profiles
|
- profiles
|
||||||
---
|
---
|
||||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||||
- assertVisible: "(?s).*Zulu Zone 4.*"
|
- assertVisible: "(?s).*Zulu Zone.*"
|
||||||
- tapOn: "(?s)^Libraries.*"
|
- tapOn: "(?s)^Libraries.*"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Maestro Movies"
|
visible: "Maestro Movies"
|
||||||
@@ -17,9 +17,9 @@ tags:
|
|||||||
- runFlow: ../subflows/create_guest_jellyfin_profile.yaml
|
- runFlow: ../subflows/create_guest_jellyfin_profile.yaml
|
||||||
- tapOn: "(?s)^E\nE2E Guest\n.*"
|
- tapOn: "(?s)^E\nE2E Guest\n.*"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s).*Guest Galaxy$"
|
visible: "(?s).*Guest Galaxy.*"
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
- assertNotVisible: "(?s).*Zulu Zone 4.*"
|
- assertNotVisible: "(?s).*Zulu Zone.*"
|
||||||
- tapOn: "(?s)^Libraries.*"
|
- tapOn: "(?s)^Libraries.*"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Guest Movies"
|
visible: "Guest Movies"
|
||||||
@@ -27,7 +27,7 @@ tags:
|
|||||||
- assertNotVisible: "Maestro Movies"
|
- assertNotVisible: "Maestro Movies"
|
||||||
- tapOn: "(?s)^Home.*"
|
- tapOn: "(?s)^Home.*"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s).*Guest Galaxy$"
|
visible: "(?s).*Guest Galaxy.*"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
- tapOn: "E"
|
- tapOn: "E"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
@@ -39,9 +39,9 @@ tags:
|
|||||||
timeout: 10000
|
timeout: 10000
|
||||||
- tapOn: "(?s)^M\nMaestro\n.*"
|
- tapOn: "(?s)^M\nMaestro\n.*"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s).*Zulu Zone 4.*"
|
visible: "(?s).*Zulu Zone.*"
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
- assertNotVisible: "(?s).*Guest Galaxy$"
|
- assertNotVisible: "(?s).*Guest Galaxy.*"
|
||||||
- tapOn: "(?s)^Libraries.*"
|
- tapOn: "(?s)^Libraries.*"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Maestro Movies"
|
visible: "Maestro Movies"
|
||||||
@@ -49,7 +49,7 @@ tags:
|
|||||||
- assertNotVisible: "Guest Movies"
|
- assertNotVisible: "Guest Movies"
|
||||||
- tapOn: "(?s)^Home.*"
|
- tapOn: "(?s)^Home.*"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s).*Zulu Zone 4.*"
|
visible: "(?s).*Zulu Zone.*"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
- tapOn: "M"
|
- tapOn: "M"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
@@ -61,6 +61,6 @@ tags:
|
|||||||
timeout: 10000
|
timeout: 10000
|
||||||
- tapOn: "(?s)^E\nE2E Guest\n.*"
|
- tapOn: "(?s)^E\nE2E Guest\n.*"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s).*Guest Galaxy$"
|
visible: "(?s).*Guest Galaxy.*"
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
- assertNotVisible: "(?s).*Zulu Zone 4.*"
|
- assertNotVisible: "(?s).*Zulu Zone.*"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ tags:
|
|||||||
visible: "Profile name"
|
visible: "Profile name"
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
- assertVisible: "(?s)^E2E Guest.*"
|
- assertVisible: "(?s)^E2E Guest.*"
|
||||||
- assertVisible: "(?s)^Maestro Jellyfin.*Default.*Manage$"
|
- assertVisible: "(?s)^Maestro Jellyfin.*Default.*$"
|
||||||
- tapOn: "Manage"
|
- tapOn: "Manage"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Remove"
|
visible: "Remove"
|
||||||
@@ -29,13 +29,16 @@ tags:
|
|||||||
- assertVisible: "(?s).*E2E Guest's access to Maestro Jellyfin.*"
|
- assertVisible: "(?s).*E2E Guest's access to Maestro Jellyfin.*"
|
||||||
- tapOn: "Remove"
|
- tapOn: "Remove"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
notVisible: "(?s)^Maestro Jellyfin.*Default.*Manage$"
|
notVisible: "(?s)^Maestro Jellyfin.*"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
- assertVisible: "Delete profile"
|
- assertVisible: "Delete profile"
|
||||||
- tapOn: "Delete profile"
|
- retry:
|
||||||
- extendedWaitUntil:
|
maxRetries: 2
|
||||||
visible: "^Delete profile\\?$"
|
commands:
|
||||||
timeout: 10000
|
- tapOn: "Delete profile"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "^Delete profile\\?$"
|
||||||
|
timeout: 10000
|
||||||
- tapOn: "^Delete$"
|
- tapOn: "^Delete$"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Switch Profile"
|
visible: "Switch Profile"
|
||||||
@@ -44,5 +47,5 @@ tags:
|
|||||||
- assertVisible: "(?s)^M\nMaestro\nActive.*"
|
- assertVisible: "(?s)^M\nMaestro\nActive.*"
|
||||||
- back
|
- back
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s).*Zulu Zone 4.*"
|
visible: "(?s)^Recently Added in Maestro Movies.*"
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
|
|||||||
@@ -9,16 +9,20 @@ tags:
|
|||||||
---
|
---
|
||||||
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
|
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
|
||||||
- pressKey: "Remote Dpad Center"
|
- pressKey: "Remote Dpad Center"
|
||||||
- extendedWaitUntil:
|
- waitForAnimationToEnd:
|
||||||
visible: "Play"
|
timeout: 5000
|
||||||
timeout: 15000
|
- runFlow:
|
||||||
- pressKey: "Remote Dpad Center"
|
when:
|
||||||
|
visible: "(?s)^(Play|Resume).*$"
|
||||||
|
commands:
|
||||||
|
- pressKey: "Remote Dpad Center"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Pause"
|
visible: "Pause"
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
|
- pressKey: "back"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
notVisible: "Pause"
|
notVisible: "Pause"
|
||||||
timeout: 15000
|
timeout: 10000
|
||||||
- pressKey: "Remote Media Play Pause"
|
- pressKey: "Remote Media Play Pause"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Play"
|
visible: "Play"
|
||||||
@@ -33,7 +37,14 @@ tags:
|
|||||||
timeout: 10000
|
timeout: 10000
|
||||||
- assertNotVisible: "Overview"
|
- assertNotVisible: "Overview"
|
||||||
- pressKey: "back"
|
- pressKey: "back"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Overview"
|
||||||
|
commands:
|
||||||
|
- pressKey: "back"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s)^(Play|Resume).*$"
|
visible:
|
||||||
|
id: "tv_browse_rail_selection"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
- assertVisible: "(?s).*Zulu Zone 4.*"
|
|
||||||
|
|||||||
@@ -18,14 +18,12 @@ tags:
|
|||||||
- tapOn:
|
- tapOn:
|
||||||
point: "87%,90%"
|
point: "87%,90%"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s).*Maestro Show$"
|
visible: "(?s)^Maestro Show, TV show, (?:watched|unwatched)$"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
- tapOn:
|
- tapOn: "(?s)^Maestro Show, TV show, (?:watched|unwatched)$"
|
||||||
point: "50%,24%"
|
|
||||||
- waitForAnimationToEnd:
|
- waitForAnimationToEnd:
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
- tapOn:
|
- tapOn: "(?s)^Play S1E1$"
|
||||||
point: "15%,76%"
|
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Pause"
|
visible: "Pause"
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
@@ -34,7 +32,16 @@ tags:
|
|||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Next Episode"
|
visible: "Next Episode"
|
||||||
timeout: 20000
|
timeout: 20000
|
||||||
- pressKey: "back"
|
- runFlow:
|
||||||
|
when:
|
||||||
|
platform: iOS
|
||||||
|
commands:
|
||||||
|
- tapOn: "Cancel"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
platform: Android
|
||||||
|
commands:
|
||||||
|
- pressKey: "back"
|
||||||
- assertNotVisible: "Next Episode"
|
- assertNotVisible: "Next Episode"
|
||||||
- assertNotVisible: "Overview"
|
- assertNotVisible: "Overview"
|
||||||
- pressKey: "Remote Dpad Up"
|
- pressKey: "Remote Dpad Up"
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ tags:
|
|||||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||||
- tapOn: "(?s)^Libraries.*"
|
- tapOn: "(?s)^Libraries.*"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s).*Zulu Zone 4$"
|
visible: "(?s).*Zulu Zone.*"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
- tapOn: "(?s).*Zulu Zone 4$"
|
- tapOn: "(?s).*Zulu Zone.*"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Play"
|
visible: "Play"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,11 +30,13 @@ appId: com.edde746.plezy
|
|||||||
- inputText: "maestro"
|
- inputText: "maestro"
|
||||||
- tapOn: "Password"
|
- tapOn: "Password"
|
||||||
- inputText: "maestro"
|
- inputText: "maestro"
|
||||||
- hideKeyboard:
|
- pressKey: ENTER
|
||||||
optional: true
|
- retry:
|
||||||
- tapOn:
|
maxRetries: 1
|
||||||
text: "Sign in"
|
commands:
|
||||||
optional: true
|
- tapOn:
|
||||||
- extendedWaitUntil:
|
text: "Sign in"
|
||||||
visible: "Discover"
|
optional: true
|
||||||
timeout: 30000
|
- extendedWaitUntil:
|
||||||
|
visible: "Discover"
|
||||||
|
timeout: 30000
|
||||||
|
|||||||
@@ -50,5 +50,6 @@ appId: com.edde746.plezy
|
|||||||
duration: 500
|
duration: 500
|
||||||
- tapOn: "(?s)^Force TV mode.*"
|
- tapOn: "(?s)^Force TV mode.*"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s).*Zulu Zone 4.*"
|
visible:
|
||||||
|
id: "tv_browse_rail_selection"
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
|
|||||||
@@ -7,11 +7,20 @@ appId: com.edde746.plezy
|
|||||||
timeout: 10000
|
timeout: 10000
|
||||||
- tapOn: "Search movies, shows, music..."
|
- tapOn: "Search movies, shows, music..."
|
||||||
- inputText: "${SAMPLE_TITLE}"
|
- inputText: "${SAMPLE_TITLE}"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
platform: iOS
|
||||||
|
commands:
|
||||||
|
- pressKey: ENTER
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
platform: Android
|
||||||
|
commands:
|
||||||
|
- hideKeyboard
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s).*${SAMPLE_TITLE}$"
|
visible: "(?s)^${SAMPLE_TITLE}, movie, (?:watched|unwatched)$"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
- tapOn:
|
- tapOn: "(?s)^${SAMPLE_TITLE}, movie, (?:watched|unwatched)$"
|
||||||
point: "50%,24%"
|
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Overview"
|
visible: "Overview"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
@@ -22,4 +31,14 @@ appId: com.edde746.plezy
|
|||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
notVisible: "Overview"
|
notVisible: "Overview"
|
||||||
timeout: 15000
|
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$"
|
||||||
|
|||||||
@@ -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.*"
|
||||||
@@ -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}"
|
|
||||||
@@ -1,23 +1,19 @@
|
|||||||
appId: com.edde746.plezy
|
appId: com.edde746.plezy
|
||||||
---
|
---
|
||||||
- runFlow:
|
|
||||||
file: show_codec_track_sheet.yaml
|
|
||||||
env:
|
|
||||||
SHEET_MARKER: "${AUDIO_TRACK}"
|
|
||||||
- assertVisible: "${AUDIO_TRACK}"
|
- assertVisible: "${AUDIO_TRACK}"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
point: "30%,70%"
|
text: "${AUDIO_TRACK}"
|
||||||
- runFlow:
|
- extendedWaitUntil:
|
||||||
file: show_codec_track_sheet.yaml
|
visible: "(?s)^.*Audio & Subtitles$"
|
||||||
env:
|
timeout: 30000
|
||||||
SHEET_MARKER: "${AUDIO_TRACK}"
|
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||||
- assertVisible: "${SUBTITLE_TRACK}"
|
- assertVisible: "${SUBTITLE_TRACK}"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
text: "${SUBTITLE_TRACK}"
|
text: "${SUBTITLE_TRACK}"
|
||||||
- runFlow:
|
- extendedWaitUntil:
|
||||||
file: show_codec_track_sheet.yaml
|
visible: "(?s)^.*Audio & Subtitles$"
|
||||||
env:
|
timeout: 30000
|
||||||
SHEET_MARKER: "${AUDIO_TRACK}"
|
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||||
- assertVisible:
|
- assertVisible:
|
||||||
text: "${AUDIO_TRACK}"
|
text: "${AUDIO_TRACK}"
|
||||||
selected: true
|
selected: true
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
appId: com.edde746.plezy
|
appId: com.edde746.plezy
|
||||||
---
|
---
|
||||||
- runFlow:
|
|
||||||
file: show_codec_track_sheet.yaml
|
|
||||||
env:
|
|
||||||
SHEET_MARKER: "${SHEET_MARKER}"
|
|
||||||
- assertVisible: "${AUDIO_TRACK}"
|
- assertVisible: "${AUDIO_TRACK}"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
point: "30%,84%"
|
text: "${AUDIO_TRACK}"
|
||||||
- runFlow:
|
- extendedWaitUntil:
|
||||||
file: show_codec_track_sheet.yaml
|
visible: "(?s)^.*Audio & Subtitles$"
|
||||||
env:
|
timeout: 30000
|
||||||
SHEET_MARKER: "${AUDIO_TRACK}"
|
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||||
- repeat:
|
- repeat:
|
||||||
times: 12
|
times: 12
|
||||||
while:
|
while:
|
||||||
@@ -23,13 +19,22 @@ appId: com.edde746.plezy
|
|||||||
- assertVisible: "${SUBTITLE_TRACK}"
|
- assertVisible: "${SUBTITLE_TRACK}"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
text: "${SUBTITLE_TRACK}"
|
text: "${SUBTITLE_TRACK}"
|
||||||
- runFlow:
|
- extendedWaitUntil:
|
||||||
file: show_codec_track_sheet.yaml
|
visible: "(?s)^.*Audio & Subtitles$"
|
||||||
env:
|
timeout: 30000
|
||||||
SHEET_MARKER: "${AUDIO_TRACK}"
|
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||||
- assertVisible:
|
- assertVisible:
|
||||||
text: "${AUDIO_TRACK}"
|
text: "${AUDIO_TRACK}"
|
||||||
selected: true
|
selected: true
|
||||||
|
- repeat:
|
||||||
|
times: 12
|
||||||
|
while:
|
||||||
|
notVisible: "${SUBTITLE_TRACK}"
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
start: 70%, 82%
|
||||||
|
end: 70%, 68%
|
||||||
|
duration: 300
|
||||||
- assertVisible:
|
- assertVisible:
|
||||||
text: "${SUBTITLE_TRACK}"
|
text: "${SUBTITLE_TRACK}"
|
||||||
selected: true
|
selected: true
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import urllib.request
|
|||||||
_FAULT_PATHS = {
|
_FAULT_PATHS = {
|
||||||
"music-failure": lambda path: path.startswith("/Artists/AlbumArtists"),
|
"music-failure": lambda path: path.startswith("/Artists/AlbumArtists"),
|
||||||
"recovery": lambda path: path.startswith("/Videos/") and "/stream" in path,
|
"recovery": lambda path: path.startswith("/Videos/") and "/stream" in path,
|
||||||
|
"offline": lambda _path: False,
|
||||||
}
|
}
|
||||||
_FORWARD_HEADERS = {
|
_FORWARD_HEADERS = {
|
||||||
"accept",
|
"accept",
|
||||||
@@ -50,6 +51,7 @@ class ProxyState:
|
|||||||
self.journal = journal
|
self.journal = journal
|
||||||
self._fault_injected = False
|
self._fault_injected = False
|
||||||
self._sequence = 0
|
self._sequence = 0
|
||||||
|
self._offline_enabled = False
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
if journal is not None:
|
if journal is not None:
|
||||||
journal.parent.mkdir(parents=True, exist_ok=True)
|
journal.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -65,6 +67,14 @@ class ProxyState:
|
|||||||
self._fault_injected = True
|
self._fault_injected = True
|
||||||
return 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:
|
def record(self, *, method: str, path: str, status: int, kind: str) -> None:
|
||||||
if self.journal is None:
|
if self.journal is None:
|
||||||
return
|
return
|
||||||
@@ -111,15 +121,28 @@ class JellyfinProxyHandler(BaseHTTPRequestHandler):
|
|||||||
self._proxy()
|
self._proxy()
|
||||||
|
|
||||||
def _proxy(self) -> None:
|
def _proxy(self) -> None:
|
||||||
if self.state.should_fault(self.path):
|
if urllib.parse.urlsplit(self.path).path == "/__maestro/offline":
|
||||||
payload = json.dumps({"error": "temporary Maestro fault"}).encode("utf-8")
|
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_response(HTTPStatus.SERVICE_UNAVAILABLE)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
self.send_header("Content-Length", str(len(payload)))
|
self.send_header("Content-Length", str(len(payload)))
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
if self.command != "HEAD":
|
if self.command != "HEAD":
|
||||||
self.wfile.write(payload)
|
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.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
|
return
|
||||||
|
|
||||||
content_length = int(self.headers.get("Content-Length", "0"))
|
content_length = int(self.headers.get("Content-Length", "0"))
|
||||||
@@ -149,6 +172,7 @@ class JellyfinProxyHandler(BaseHTTPRequestHandler):
|
|||||||
status = HTTPStatus.BAD_GATEWAY
|
status = HTTPStatus.BAD_GATEWAY
|
||||||
response_headers = {"Content-Type": "application/json"}
|
response_headers = {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
self.state.record(method=self.command, path=self.path, status=int(status), kind="request")
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
for name, value in response_headers.items():
|
for name, value in response_headers.items():
|
||||||
if name.lower() in _RESPONSE_HEADERS:
|
if name.lower() in _RESPONSE_HEADERS:
|
||||||
@@ -160,7 +184,19 @@ class JellyfinProxyHandler(BaseHTTPRequestHandler):
|
|||||||
self.wfile.write(payload)
|
self.wfile.write(payload)
|
||||||
except (BrokenPipeError, ConnectionResetError):
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
pass
|
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:
|
def log_message(self, format: str, *args: Any) -> None:
|
||||||
print(f"jellyfin-proxy: {format % args}")
|
print(f"jellyfin-proxy: {format % args}")
|
||||||
|
|||||||
+20
-6
@@ -20,7 +20,7 @@ import urllib.request
|
|||||||
|
|
||||||
ROOT_DIR = Path(__file__).resolve().parent.parent
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||||
APP_ID = "com.edde746.plezy"
|
APP_ID = "com.edde746.plezy"
|
||||||
FAULTS = ("music-failure", "recovery")
|
FAULTS = ("music-failure", "offline", "recovery")
|
||||||
|
|
||||||
|
|
||||||
class RunnerError(RuntimeError):
|
class RunnerError(RuntimeError):
|
||||||
@@ -316,6 +316,16 @@ def _require_commands(names: Sequence[str]) -> None:
|
|||||||
raise RunnerError(f"Required command not found: {', '.join(missing)}")
|
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:
|
def build_jellyfin_image(config: RunnerConfig) -> None:
|
||||||
_require_commands(("docker",))
|
_require_commands(("docker",))
|
||||||
command = (
|
command = (
|
||||||
@@ -373,14 +383,14 @@ class MaestroRunner:
|
|||||||
if not self.config.skip_jellyfin_build:
|
if not self.config.skip_jellyfin_build:
|
||||||
build_jellyfin_image(self.config)
|
build_jellyfin_image(self.config)
|
||||||
self._start_jellyfin()
|
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:
|
if self.config.jellyfin_fault:
|
||||||
self._start_proxy()
|
self._start_proxy()
|
||||||
|
|
||||||
if not self.config.skip_build:
|
if not self.config.skip_build:
|
||||||
_run_checked(("flutter", "pub", "get"))
|
_run_checked(("flutter", "pub", "get"))
|
||||||
_run_checked(("flutter", "build", "apk", "--debug"))
|
_run_checked(flutter_build_command())
|
||||||
|
|
||||||
self._prepare_device()
|
self._prepare_device()
|
||||||
_run_checked((*self.adb_prefix, "install", "-r", self.config.apk_path))
|
_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}"
|
default_url = f"http://127.0.0.1:{self.device_service_port}"
|
||||||
jellyfin_url = self.config.jellyfin_url or default_url
|
jellyfin_url = self.config.jellyfin_url or default_url
|
||||||
command = ["maestro", "test", "-e", f"JELLYFIN_URL={jellyfin_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:
|
if self.device_id:
|
||||||
command.extend(("--device", self.device_id))
|
command.extend(("--device", self.device_id))
|
||||||
if self.config.maestro_config:
|
if self.config.maestro_config:
|
||||||
@@ -451,11 +463,13 @@ class MaestroRunner:
|
|||||||
for _ in range(attempts):
|
for _ in range(attempts):
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(health_url, timeout=1) as response:
|
with urllib.request.urlopen(health_url, timeout=1) as response:
|
||||||
response.read()
|
status = response.read().decode(errors="replace").strip()
|
||||||
return
|
if status.casefold() == "healthy":
|
||||||
|
return
|
||||||
|
last_error = RunnerError(f"{service} health status is {status or 'empty'}")
|
||||||
except (OSError, urllib.error.URLError) as error:
|
except (OSError, urllib.error.URLError) as error:
|
||||||
last_error = error
|
last_error = error
|
||||||
time.sleep(interval)
|
time.sleep(interval)
|
||||||
raise RunnerError(f"{service} did not become ready at {base_url}: {last_error}")
|
raise RunnerError(f"{service} did not become ready at {base_url}: {last_error}")
|
||||||
|
|
||||||
def _start_proxy(self) -> None:
|
def _start_proxy(self) -> None:
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ from collections.abc import Sequence
|
|||||||
import run_maestro
|
import run_maestro
|
||||||
|
|
||||||
|
|
||||||
|
ANDROID_15_INSTRUMENTATION_CLASSES = (
|
||||||
|
"androidx.media3.decoder.ffmpeg.PlezyFfmpegPlaybackTest,"
|
||||||
|
"com.edde746.plezy.exoplayer.PlezyAudioModePlaybackTest"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
GROUPS: dict[str, tuple[tuple[str, ...], ...]] = {
|
GROUPS: dict[str, tuple[tuple[str, ...], ...]] = {
|
||||||
"android-15": (
|
"android-15": (
|
||||||
("basic",),
|
("basic",),
|
||||||
@@ -54,6 +60,21 @@ GROUPS: dict[str, tuple[tuple[str, ...], ...]] = {
|
|||||||
"--diagnostics-dir",
|
"--diagnostics-dir",
|
||||||
"build/maestro-recovery/music-diagnostics",
|
"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",
|
"basic",
|
||||||
"--fault",
|
"--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:
|
def run_group(name: str) -> int:
|
||||||
|
if name == "android-15":
|
||||||
|
run_android_15_instrumentation()
|
||||||
failed = False
|
failed = False
|
||||||
for arguments in GROUPS[name]:
|
for arguments in GROUPS[name]:
|
||||||
print(f"==> Maestro {' '.join(arguments)}", flush=True)
|
print(f"==> Maestro {' '.join(arguments)}", flush=True)
|
||||||
|
|||||||
Executable
+200
@@ -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()
|
||||||
@@ -47,6 +47,16 @@ class _UpstreamHandler(BaseHTTPRequestHandler):
|
|||||||
def log_message(self, format: str, *args: object) -> None:
|
def log_message(self, format: str, *args: object) -> None:
|
||||||
pass
|
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):
|
class JellyfinProxyTests(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
@@ -121,6 +131,40 @@ class JellyfinProxyTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
self._stop_proxy(proxy, thread)
|
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:
|
def test_music_fault_does_not_affect_other_requests(self) -> None:
|
||||||
proxy, thread, base_url, _ = self._start_proxy("music-failure")
|
proxy, thread, base_url, _ = self._start_proxy("music-failure")
|
||||||
try:
|
try:
|
||||||
@@ -135,6 +179,27 @@ class JellyfinProxyTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
self._stop_proxy(proxy, thread)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
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:
|
def test_explicit_device_url_wins_over_network_mode(self) -> None:
|
||||||
config = run_maestro.parse_config(
|
config = run_maestro.parse_config(
|
||||||
["basic", "--adb-reverse", "--jellyfin-url", "http://device.test:9000"],
|
["basic", "--adb-reverse", "--jellyfin-url", "http://device.test:9000"],
|
||||||
@@ -134,6 +158,21 @@ class LifecycleTests(unittest.TestCase):
|
|||||||
self.assertEqual(run_command.call_count, 2)
|
self.assertEqual(run_command.call_count, 2)
|
||||||
sleep.assert_called_once_with(5)
|
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):
|
class CiGroupTests(unittest.TestCase):
|
||||||
def test_android_15_group_runs_every_suite_after_failure(self) -> None:
|
def test_android_15_group_runs_every_suite_after_failure(self) -> None:
|
||||||
@@ -142,12 +181,14 @@ class CiGroupTests(unittest.TestCase):
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(run_maestro_ci.run_maestro, "main", side_effect=statuses) as run,
|
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()),
|
redirect_stdout(io.StringIO()),
|
||||||
):
|
):
|
||||||
exit_status = run_maestro_ci.run_group("android-15")
|
exit_status = run_maestro_ci.run_group("android-15")
|
||||||
|
|
||||||
self.assertEqual(exit_status, 1)
|
self.assertEqual(exit_status, 1)
|
||||||
self.assertEqual(run.call_count, expected_runs)
|
self.assertEqual(run.call_count, expected_runs)
|
||||||
|
instrumentation.assert_called_once_with()
|
||||||
|
|
||||||
def test_group_recipes_are_valid_runner_invocations(self) -> None:
|
def test_group_recipes_are_valid_runner_invocations(self) -> None:
|
||||||
for recipes in run_maestro_ci.GROUPS.values():
|
for recipes in run_maestro_ci.GROUPS.values():
|
||||||
@@ -158,12 +199,14 @@ class CiGroupTests(unittest.TestCase):
|
|||||||
def test_group_stops_after_interruption(self) -> None:
|
def test_group_stops_after_interruption(self) -> None:
|
||||||
with (
|
with (
|
||||||
patch.object(run_maestro_ci.run_maestro, "main", return_value=143) as run,
|
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()),
|
redirect_stdout(io.StringIO()),
|
||||||
):
|
):
|
||||||
exit_status = run_maestro_ci.run_group("android-15")
|
exit_status = run_maestro_ci.run_group("android-15")
|
||||||
|
|
||||||
self.assertEqual(exit_status, 143)
|
self.assertEqual(exit_status, 143)
|
||||||
run.assert_called_once_with(("basic",))
|
run.assert_called_once_with(("basic",))
|
||||||
|
instrumentation.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+30
-3
@@ -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
|
// Unit tests — pure logic
|
||||||
// ======================================================================
|
// ======================================================================
|
||||||
@@ -1261,9 +1289,8 @@ func TestCleanupDisconnectsPeersBeforeRemovingExpiredOccupiedRoom(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
for name, connection := range map[string]*testConn{"host": host, "guest": guest} {
|
for name, connection := range map[string]*testConn{"host": host, "guest": guest} {
|
||||||
connection.conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
if _, err := connection.recvUntilClosed(2 * time.Second); err != nil {
|
||||||
if _, _, err := connection.conn.ReadMessage(); err == nil {
|
t.Errorf("%s did not reach terminal closure: %v", name, err)
|
||||||
t.Errorf("%s remained connected after occupied room removal", name)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ void main() {
|
|||||||
|
|
||||||
tearDown(() {
|
tearDown(() {
|
||||||
SettingsService.resetForTesting();
|
SettingsService.resetForTesting();
|
||||||
resetSharedPreferencesForTest();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('only custom players expose a focusable delete action', (tester) async {
|
testWidgets('only custom players expose a focusable delete action', (tester) async {
|
||||||
|
|||||||
@@ -39,18 +39,22 @@ class _DelayedCountingHttpClient extends http.BaseClient {
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late Directory tmpRoot;
|
late Directory tmpRoot;
|
||||||
|
late PathProviderPlatform previousPathProvider;
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
resetSharedPreferencesForTest();
|
resetSharedPreferencesForTest();
|
||||||
SettingsService.resetForTesting();
|
SettingsService.resetForTesting();
|
||||||
DownloadStorageService.resetForTesting();
|
DownloadStorageService.resetForTesting();
|
||||||
tmpRoot = await Directory.systemTemp.createTemp('download_artwork_service_test_');
|
tmpRoot = await Directory.systemTemp.createTemp('download_artwork_service_test_');
|
||||||
|
previousPathProvider = PathProviderPlatform.instance;
|
||||||
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
DownloadStorageService.resetForTesting();
|
DownloadStorageService.resetForTesting();
|
||||||
SettingsService.resetForTesting();
|
SettingsService.resetForTesting();
|
||||||
|
PathProviderPlatform.instance = previousPathProvider;
|
||||||
|
expect(PathProviderPlatform.instance, same(previousPathProvider));
|
||||||
if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true);
|
if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -214,10 +214,13 @@ void main() {
|
|||||||
SettingsService.resetForTesting();
|
SettingsService.resetForTesting();
|
||||||
DownloadStorageService.resetForTesting();
|
DownloadStorageService.resetForTesting();
|
||||||
final tmpRoot = await Directory.systemTemp.createTemp('download_manager_artwork_repair_test_');
|
final tmpRoot = await Directory.systemTemp.createTemp('download_manager_artwork_repair_test_');
|
||||||
|
final previousPathProvider = PathProviderPlatform.instance;
|
||||||
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
||||||
addTearDown(() async {
|
addTearDown(() async {
|
||||||
DownloadStorageService.resetForTesting();
|
DownloadStorageService.resetForTesting();
|
||||||
SettingsService.resetForTesting();
|
SettingsService.resetForTesting();
|
||||||
|
PathProviderPlatform.instance = previousPathProvider;
|
||||||
|
expect(PathProviderPlatform.instance, same(previousPathProvider));
|
||||||
if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true);
|
if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -308,10 +311,13 @@ void main() {
|
|||||||
SettingsService.resetForTesting();
|
SettingsService.resetForTesting();
|
||||||
DownloadStorageService.resetForTesting();
|
DownloadStorageService.resetForTesting();
|
||||||
final tmpRoot = await Directory.systemTemp.createTemp('download_manager_delete_test_');
|
final tmpRoot = await Directory.systemTemp.createTemp('download_manager_delete_test_');
|
||||||
|
final previousPathProvider = PathProviderPlatform.instance;
|
||||||
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
||||||
addTearDown(() async {
|
addTearDown(() async {
|
||||||
DownloadStorageService.resetForTesting();
|
DownloadStorageService.resetForTesting();
|
||||||
SettingsService.resetForTesting();
|
SettingsService.resetForTesting();
|
||||||
|
PathProviderPlatform.instance = previousPathProvider;
|
||||||
|
expect(PathProviderPlatform.instance, same(previousPathProvider));
|
||||||
if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true);
|
if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -606,6 +612,7 @@ Future<_DeletionResult> _runEpisodeDeletion({required bool saf, bool failVideoDe
|
|||||||
SettingsService.resetForTesting();
|
SettingsService.resetForTesting();
|
||||||
DownloadStorageService.resetForTesting();
|
DownloadStorageService.resetForTesting();
|
||||||
final tmpRoot = await Directory.systemTemp.createTemp('download_manager_backend_delete_test_');
|
final tmpRoot = await Directory.systemTemp.createTemp('download_manager_backend_delete_test_');
|
||||||
|
final previousPathProvider = PathProviderPlatform.instance;
|
||||||
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
||||||
|
|
||||||
final storage = saf ? DownloadStorageService.forTestingSaf('content://downloads') : DownloadStorageService.instance;
|
final storage = saf ? DownloadStorageService.forTestingSaf('content://downloads') : DownloadStorageService.instance;
|
||||||
@@ -710,6 +717,8 @@ Future<_DeletionResult> _runEpisodeDeletion({required bool saf, bool failVideoDe
|
|||||||
await db.close();
|
await db.close();
|
||||||
DownloadStorageService.resetForTesting();
|
DownloadStorageService.resetForTesting();
|
||||||
SettingsService.resetForTesting();
|
SettingsService.resetForTesting();
|
||||||
|
PathProviderPlatform.instance = previousPathProvider;
|
||||||
|
expect(PathProviderPlatform.instance, same(previousPathProvider));
|
||||||
if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true);
|
if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -719,6 +728,7 @@ Future<_ContainerDeletionResult> _runContainerDeletion({required MediaKind kind,
|
|||||||
SettingsService.resetForTesting();
|
SettingsService.resetForTesting();
|
||||||
DownloadStorageService.resetForTesting();
|
DownloadStorageService.resetForTesting();
|
||||||
final tmpRoot = await Directory.systemTemp.createTemp('download_manager_container_delete_test_');
|
final tmpRoot = await Directory.systemTemp.createTemp('download_manager_container_delete_test_');
|
||||||
|
final previousPathProvider = PathProviderPlatform.instance;
|
||||||
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
||||||
|
|
||||||
final storage = saf ? DownloadStorageService.forTestingSaf('content://downloads') : DownloadStorageService.instance;
|
final storage = saf ? DownloadStorageService.forTestingSaf('content://downloads') : DownloadStorageService.instance;
|
||||||
@@ -796,6 +806,8 @@ Future<_ContainerDeletionResult> _runContainerDeletion({required MediaKind kind,
|
|||||||
await db.close();
|
await db.close();
|
||||||
DownloadStorageService.resetForTesting();
|
DownloadStorageService.resetForTesting();
|
||||||
SettingsService.resetForTesting();
|
SettingsService.resetForTesting();
|
||||||
|
PathProviderPlatform.instance = previousPathProvider;
|
||||||
|
expect(PathProviderPlatform.instance, same(previousPathProvider));
|
||||||
if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true);
|
if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,18 +16,22 @@ import '../test_helpers/media_items.dart';
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late Directory tmpRoot;
|
late Directory tmpRoot;
|
||||||
|
late PathProviderPlatform previousPathProvider;
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
resetSharedPreferencesForTest();
|
resetSharedPreferencesForTest();
|
||||||
SettingsService.resetForTesting();
|
SettingsService.resetForTesting();
|
||||||
DownloadStorageService.resetForTesting();
|
DownloadStorageService.resetForTesting();
|
||||||
tmpRoot = await Directory.systemTemp.createTemp('dss_test_');
|
tmpRoot = await Directory.systemTemp.createTemp('dss_test_');
|
||||||
|
previousPathProvider = PathProviderPlatform.instance;
|
||||||
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
DownloadStorageService.resetForTesting();
|
DownloadStorageService.resetForTesting();
|
||||||
SettingsService.resetForTesting();
|
SettingsService.resetForTesting();
|
||||||
|
PathProviderPlatform.instance = previousPathProvider;
|
||||||
|
expect(PathProviderPlatform.instance, same(previousPathProvider));
|
||||||
if (await tmpRoot.exists()) {
|
if (await tmpRoot.exists()) {
|
||||||
await tmpRoot.delete(recursive: true);
|
await tmpRoot.delete(recursive: true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,23 +14,6 @@ import 'package:plezy/services/multi_server_manager.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../test_helpers/media_items.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}) =>
|
MediaItem _meta(String id, {String? title}) =>
|
||||||
testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: title ?? 'Episode $id');
|
testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: title ?? 'Episode $id');
|
||||||
|
|
||||||
|
|||||||
@@ -30,30 +30,13 @@ JellyfinConnection _jellyfinConnection(String userId) => testJellyfinConnection(
|
|||||||
|
|
||||||
JellyfinClient _jellyfinClient(String userId) => testJellyfinClient(connection: _jellyfinConnection(userId));
|
JellyfinClient _jellyfinClient(String userId) => testJellyfinClient(connection: _jellyfinConnection(userId));
|
||||||
|
|
||||||
// NOTE on coverage scope:
|
// Coverage includes status and lifecycle changes, endpoint exhaustion,
|
||||||
// [MultiServerManager.addServer] / `connectToAllServers` / `_createClientForServer`
|
// in-place Plex token refresh, Jellyfin reuse/update, and selected
|
||||||
// all instantiate a real `PlexClient` via `findBestWorkingConnection`, which
|
// registered-Jellyfin `checkServerHealth` outcomes. First-time
|
||||||
// performs live HTTP calls to a Plex Media Server. The manager does NOT expose
|
// `addPlexAccount` and `refreshTokensForProfile` fallback construction through
|
||||||
// a fake `PlexClient` factory, so per the task brief we don't fake the network
|
// `_createClientForServer`, Plex and mixed-client health/coalescing,
|
||||||
// here.
|
// `_reoptimizeServer`, and `_startNetworkMonitoring` subscription/debounce
|
||||||
//
|
// behavior remain outside this suite.
|
||||||
// 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
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
setUp(resetSharedPreferencesForTest);
|
setUp(resetSharedPreferencesForTest);
|
||||||
|
|||||||
@@ -23,27 +23,13 @@ import '../test_helpers/backend_client_fixtures.dart';
|
|||||||
import '../test_helpers/prefs.dart';
|
import '../test_helpers/prefs.dart';
|
||||||
import '../test_helpers/media_items.dart';
|
import '../test_helpers/media_items.dart';
|
||||||
|
|
||||||
// NOTE on coverage scope:
|
// Direct `syncPendingItems` coverage exercises retry retention, Plex/Jellyfin
|
||||||
// The actual sync-to-server path (`syncPendingItems`, `syncWatchStatesFromServer`,
|
// progress replay, profile interruption, and scoped Jellyfin routing. Direct
|
||||||
// `_performBidirectionalSync`) all reach into a real `PlexClient` via the
|
// `syncWatchStatesFromServer` coverage exercises active-profile and
|
||||||
// injected `MultiServerManager`. Per the task brief we do NOT exercise those
|
// active-scope routing plus selected watched outcomes. Trigger coalescing and
|
||||||
// paths here — they require either a fake `PlexClient` factory or live HTTP.
|
// throttle sequencing inside `_performBidirectionalSync`, direct cache-row and
|
||||||
//
|
// refresh-callback assertions, and non-default watched-threshold sources remain
|
||||||
// What IS covered:
|
// outside this suite.
|
||||||
// - 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.
|
|
||||||
|
|
||||||
/// Minimal [OfflineModeSource] that lets tests flip the offline flag and
|
/// Minimal [OfflineModeSource] that lets tests flip the offline flag and
|
||||||
/// observe `addListener`/`removeListener` traffic via the protected
|
/// observe `addListener`/`removeListener` traffic via the protected
|
||||||
|
|||||||
@@ -30,12 +30,14 @@ void main() {
|
|||||||
|
|
||||||
late AppDatabase db;
|
late AppDatabase db;
|
||||||
late Directory tmpRoot;
|
late Directory tmpRoot;
|
||||||
|
late PathProviderPlatform previousPathProvider;
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
resetSharedPreferencesForTest();
|
resetSharedPreferencesForTest();
|
||||||
SettingsService.resetForTesting();
|
SettingsService.resetForTesting();
|
||||||
DownloadStorageService.resetForTesting();
|
DownloadStorageService.resetForTesting();
|
||||||
tmpRoot = await Directory.systemTemp.createTemp('playback_init_test_');
|
tmpRoot = await Directory.systemTemp.createTemp('playback_init_test_');
|
||||||
|
previousPathProvider = PathProviderPlatform.instance;
|
||||||
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
||||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
PlexApiCache.initialize(db);
|
PlexApiCache.initialize(db);
|
||||||
@@ -46,6 +48,8 @@ void main() {
|
|||||||
await db.close();
|
await db.close();
|
||||||
DownloadStorageService.resetForTesting();
|
DownloadStorageService.resetForTesting();
|
||||||
SettingsService.resetForTesting();
|
SettingsService.resetForTesting();
|
||||||
|
PathProviderPlatform.instance = previousPathProvider;
|
||||||
|
expect(PathProviderPlatform.instance, same(previousPathProvider));
|
||||||
if (await tmpRoot.exists()) {
|
if (await tmpRoot.exists()) {
|
||||||
await tmpRoot.delete(recursive: true);
|
await tmpRoot.delete(recursive: true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:plezy/services/base_shared_preferences_service.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/shared_preferences.dart';
|
||||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.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_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.
|
/// Reset shared-prefs platform mocks AND the cached singleton instances.
|
||||||
/// Call from `setUp` so each test starts with a clean slate.
|
/// Call from `setUp` so each test starts with a clean slate.
|
||||||
void resetSharedPreferencesForTest({Map<String, Object> initialAsync = const {}}) {
|
void resetSharedPreferencesForTest({Map<String, Object> 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();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
SharedPreferencesAsyncPlatform.instance = initialAsync.isEmpty
|
SharedPreferencesAsyncPlatform.instance = initialAsync.isEmpty
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
});
|
||||||
|
}
|
||||||
+188
-112
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'package:fake_async/fake_async.dart';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:plezy/utils/endpoint_race.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 {
|
test('healthy cached endpoint wins within the head start without racing', () {
|
||||||
await Future<void>.delayed(delay);
|
fakeAsync((async) {
|
||||||
return (url: url, ok: ok);
|
final probeCounts = <String, int>{};
|
||||||
}
|
final selections = <EndpointRaceSelection<String, _Result>>[];
|
||||||
|
late Completer<_Result> cachedGate;
|
||||||
|
|
||||||
test('healthy cached endpoint wins within the head start without racing', () async {
|
race(
|
||||||
final probeCounts = <String, int>{};
|
candidates: ['a', 'cached'],
|
||||||
final selections = await race(
|
preferred: 'cached',
|
||||||
candidates: ['a', 'cached'],
|
probe: (url) {
|
||||||
preferred: 'cached',
|
probeCounts[url] = (probeCounts[url] ?? 0) + 1;
|
||||||
probe: (url) {
|
final gate = Completer<_Result>();
|
||||||
probeCounts[url] = (probeCounts[url] ?? 0) + 1;
|
if (url == 'cached') cachedGate = gate;
|
||||||
return resultAfter(url, const Duration(milliseconds: 10), ok: true);
|
return gate.future;
|
||||||
},
|
},
|
||||||
).toList();
|
).listen(selections.add);
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
expect(selections.first.phase, EndpointRacePhase.first);
|
expect(probeCounts, {'cached': 1});
|
||||||
expect(selections.first.candidate, 'cached');
|
cachedGate.complete((url: 'cached', ok: true));
|
||||||
expect(selections.first.fromPreferred, isTrue);
|
async.flushMicrotasks();
|
||||||
expect(probeCounts['cached'], 1);
|
|
||||||
// The race never started; only the phase-2 measure touches other URLs.
|
expect(selections.first.phase, EndpointRacePhase.first);
|
||||||
expect(probeCounts.containsKey('a'), isFalse);
|
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 {
|
test('stale-slow cached endpoint overlaps the race instead of serially blocking it', () {
|
||||||
final probeCounts = <String, int>{};
|
fakeAsync((async) {
|
||||||
final stopwatch = Stopwatch()..start();
|
final probeCounts = <String, int>{};
|
||||||
final firstTimes = <int>[];
|
final selections = <EndpointRaceSelection<String, _Result>>[];
|
||||||
final selections = <EndpointRaceSelection<String, _Result>>[];
|
late Completer<_Result> cachedGate;
|
||||||
await for (final selection in race(
|
late Completer<_Result> fastGate;
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(selections.first.candidate, 'fast');
|
race(
|
||||||
expect(selections.first.fromPreferred, isFalse);
|
candidates: ['fast', 'cached'],
|
||||||
// Emitted shortly after the head start — not after the cached probe's
|
preferred: 'cached',
|
||||||
// full budget (the pre-change serial behavior).
|
probe: (url) {
|
||||||
expect(firstTimes.first, lessThan(200));
|
probeCounts[url] = (probeCounts[url] ?? 0) + 1;
|
||||||
// The pending cached probe was merged into the race, not re-fired.
|
final gate = Completer<_Result>();
|
||||||
expect(probeCounts['cached'], 1);
|
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.
|
expect(probeCounts, {'cached': 1});
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
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 {
|
test('cached endpoint that answers after the head start still wins when first', () {
|
||||||
final probeCounts = <String, int>{};
|
fakeAsync((async) {
|
||||||
final selections = await race(
|
final probeCounts = <String, int>{};
|
||||||
candidates: ['slow', 'cached'],
|
final selections = <EndpointRaceSelection<String, _Result>>[];
|
||||||
preferred: 'cached',
|
late Completer<_Result> cachedGate;
|
||||||
probe: (url) {
|
late Completer<_Result> slowGate;
|
||||||
probeCounts[url] = (probeCounts[url] ?? 0) + 1;
|
|
||||||
return resultAfter(
|
|
||||||
url,
|
|
||||||
url == 'cached' ? const Duration(milliseconds: 120) : const Duration(milliseconds: 350),
|
|
||||||
ok: true,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
).toList();
|
|
||||||
|
|
||||||
expect(selections.first.candidate, 'cached');
|
race(
|
||||||
expect(selections.first.fromPreferred, isTrue);
|
candidates: ['slow', 'cached'],
|
||||||
expect(probeCounts['cached'], 1);
|
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<void>.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 {
|
test('cached endpoint failing within the head start falls back to a fresh race', () {
|
||||||
final probeCounts = <String, int>{};
|
fakeAsync((async) {
|
||||||
final selections = await race(
|
final probeCounts = <String, int>{};
|
||||||
candidates: ['cached', 'alt'],
|
final selections = <EndpointRaceSelection<String, _Result>>[];
|
||||||
preferred: 'cached',
|
final cachedGates = <Completer<_Result>>[];
|
||||||
probe: (url) {
|
late Completer<_Result> altGate;
|
||||||
probeCounts[url] = (probeCounts[url] ?? 0) + 1;
|
|
||||||
return resultAfter(url, const Duration(milliseconds: 10), ok: url == 'alt');
|
|
||||||
},
|
|
||||||
).toList();
|
|
||||||
|
|
||||||
expect(selections.first.candidate, 'alt');
|
race(
|
||||||
expect(selections.first.fromPreferred, isFalse);
|
candidates: ['cached', 'alt'],
|
||||||
// Fast-fail keeps today's semantics: the cached URL re-races as a
|
preferred: 'cached',
|
||||||
// normal candidate (one probe up front, one inside the race).
|
probe: (url) {
|
||||||
expect(probeCounts['cached'], 2);
|
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 {
|
test('preferred URL not among candidates skips the cached probe entirely', () {
|
||||||
final probeCounts = <String, int>{};
|
fakeAsync((async) {
|
||||||
final selections = await race(
|
final probeCounts = <String, int>{};
|
||||||
candidates: ['a', 'b'],
|
final selections = <EndpointRaceSelection<String, _Result>>[];
|
||||||
preferred: 'custom-url',
|
final gates = <String, Completer<_Result>>{};
|
||||||
probe: (url) {
|
|
||||||
probeCounts[url] = (probeCounts[url] ?? 0) + 1;
|
|
||||||
return resultAfter(url, const Duration(milliseconds: 10), ok: url == 'a');
|
|
||||||
},
|
|
||||||
).toList();
|
|
||||||
|
|
||||||
expect(selections.first.candidate, 'a');
|
race(
|
||||||
expect(selections.first.fromPreferred, isFalse);
|
candidates: ['a', 'b'],
|
||||||
expect(probeCounts.containsKey('custom-url'), isFalse);
|
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 {
|
test('emits nothing when every candidate fails', () async {
|
||||||
final selections = await race(
|
final selections = await race(
|
||||||
candidates: ['a', 'b'],
|
candidates: ['a', 'b'],
|
||||||
preferred: 'a',
|
preferred: 'a',
|
||||||
probe: (url) => resultAfter(url, const Duration(milliseconds: 10), ok: false),
|
probe: (url) async => (url: url, ok: false),
|
||||||
).toList();
|
).toList();
|
||||||
|
|
||||||
expect(selections, isEmpty);
|
expect(selections, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('phase 2 still promotes the selector-best endpoint', () async {
|
test('phase 2 still promotes the selector-best endpoint', () {
|
||||||
final selections = await race(
|
fakeAsync((async) {
|
||||||
candidates: ['quick', 'better'],
|
final selections = <EndpointRaceSelection<String, _Result>>[];
|
||||||
probe: (url) => resultAfter(
|
final gates = <String, Completer<_Result>>{};
|
||||||
url,
|
|
||||||
url == 'quick' ? const Duration(milliseconds: 10) : const Duration(milliseconds: 80),
|
|
||||||
ok: true,
|
|
||||||
),
|
|
||||||
measure: (url) async => (url: url, ok: true),
|
|
||||||
selectBest: (results) => 'better',
|
|
||||||
).toList();
|
|
||||||
|
|
||||||
expect(selections, hasLength(2));
|
race(
|
||||||
expect(selections.first.phase, EndpointRacePhase.first);
|
candidates: ['quick', 'better'],
|
||||||
expect(selections.first.candidate, 'quick');
|
probe: (url) => (gates[url] = Completer<_Result>()).future,
|
||||||
expect(selections.last.phase, EndpointRacePhase.best);
|
measure: (url) async => (url: url, ok: true),
|
||||||
expect(selections.last.candidate, 'better');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user