feat(test): add Maestro end-to-end coverage
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
**
|
||||
!.maestro/
|
||||
!.maestro/jellyfin-demo/
|
||||
!.maestro/jellyfin-demo/**
|
||||
!scripts/
|
||||
!scripts/maestro_fixtures.py
|
||||
!scripts/maestro_real_jellyfin.py
|
||||
@@ -0,0 +1,399 @@
|
||||
name: E2E - Maestro
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: maestro-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
android-maestro:
|
||||
name: Android basic, catalog, and codec flows
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 65
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
|
||||
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: "3.44.0"
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-e2e-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-e2e-
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Install Maestro
|
||||
run: |
|
||||
curl -fsSL "https://get.maestro.mobile.dev" | bash
|
||||
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Build debug APK
|
||||
run: flutter build apk --debug
|
||||
|
||||
- name: Build deterministic Jellyfin image
|
||||
run: python3 scripts/run_maestro.py build-image
|
||||
|
||||
- name: Run Maestro suites
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
api-level: 35
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
disable-animations: true
|
||||
emulator-options: >-
|
||||
-no-window -gpu swiftshader_indirect -no-snapshot -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: |
|
||||
python3 scripts/run_maestro.py basic --skip-build --skip-jellyfin-build
|
||||
python3 scripts/run_maestro.py catalog \
|
||||
--skip-build --skip-jellyfin-build --device emulator-5554
|
||||
python3 scripts/run_maestro.py media \
|
||||
--skip-build --skip-jellyfin-build --device emulator-5554
|
||||
|
||||
- name: Upload Maestro diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: maestro-android-diagnostics
|
||||
path: |
|
||||
build/maestro
|
||||
build/maestro-real-jellyfin
|
||||
build/maestro-media
|
||||
~/.maestro/tests
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
android-legacy-playback:
|
||||
name: Android 9 / API 28 Play Store playback
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 35
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
|
||||
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: "3.44.0"
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-e2e-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-e2e-
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Install Maestro
|
||||
run: |
|
||||
curl -fsSL "https://get.maestro.mobile.dev" | bash
|
||||
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Build debug APK
|
||||
run: flutter build apk --debug
|
||||
|
||||
- name: Build deterministic Jellyfin image
|
||||
run: python3 scripts/run_maestro.py build-image
|
||||
|
||||
- name: Run legacy playback flow
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
api-level: 28
|
||||
target: google_apis_playstore
|
||||
arch: x86
|
||||
profile: pixel_2
|
||||
disable-animations: true
|
||||
emulator-options: >-
|
||||
-no-window -gpu swiftshader_indirect -no-snapshot -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: |
|
||||
python3 scripts/run_maestro.py basic \
|
||||
--skip-build --skip-jellyfin-build \
|
||||
--device emulator-5554 \
|
||||
--flow .maestro/flows/05_playback.yaml \
|
||||
--jellyfin-log build/maestro-legacy/jellyfin.log \
|
||||
--diagnostics-dir build/maestro-legacy/diagnostics
|
||||
|
||||
- name: Upload legacy playback diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: maestro-android-legacy-diagnostics
|
||||
path: |
|
||||
build/maestro-legacy
|
||||
~/.maestro/tests
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
android-tv-regressions:
|
||||
name: Android TV and D-pad regressions
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
|
||||
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: "3.44.0"
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-e2e-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-e2e-
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Install Maestro
|
||||
run: |
|
||||
curl -fsSL "https://get.maestro.mobile.dev" | bash
|
||||
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Build debug APK
|
||||
run: flutter build apk --debug
|
||||
|
||||
- name: Build deterministic Jellyfin image
|
||||
run: python3 scripts/run_maestro.py build-image
|
||||
|
||||
- name: Run TV regression flows
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
api-level: 35
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
disable-animations: true
|
||||
emulator-options: >-
|
||||
-no-window -gpu swiftshader_indirect -no-snapshot -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: |
|
||||
python3 scripts/run_maestro.py basic \
|
||||
--skip-build --skip-jellyfin-build \
|
||||
--device emulator-5554 \
|
||||
--flow .maestro/regression_flows/03_tv_library_focus.yaml \
|
||||
--jellyfin-log build/maestro-tv/library-focus.log \
|
||||
--diagnostics-dir build/maestro-tv/library-focus-diagnostics
|
||||
python3 scripts/run_maestro.py basic \
|
||||
--skip-build --skip-jellyfin-build \
|
||||
--device emulator-5554 \
|
||||
--flow .maestro/regression_flows/04_tv_player_keys.yaml \
|
||||
--jellyfin-log build/maestro-tv/player-keys.log \
|
||||
--diagnostics-dir build/maestro-tv/player-keys-diagnostics
|
||||
python3 scripts/run_maestro.py basic \
|
||||
--skip-build --skip-jellyfin-build \
|
||||
--device emulator-5554 \
|
||||
--flow .maestro/regression_flows/05_tv_next_episode_back.yaml \
|
||||
--jellyfin-log build/maestro-tv/next-episode.log \
|
||||
--diagnostics-dir build/maestro-tv/next-episode-diagnostics
|
||||
|
||||
- name: Upload TV regression diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: maestro-android-tv-diagnostics
|
||||
path: |
|
||||
build/maestro-tv
|
||||
~/.maestro/tests
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
android-playback-recovery:
|
||||
name: Android playback recovery
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
|
||||
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: "3.44.0"
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-e2e-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-e2e-
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Install Maestro
|
||||
run: |
|
||||
curl -fsSL "https://get.maestro.mobile.dev" | bash
|
||||
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Build debug APK
|
||||
run: flutter build apk --debug
|
||||
|
||||
- name: Build deterministic Jellyfin image
|
||||
run: python3 scripts/run_maestro.py build-image
|
||||
|
||||
- name: Run media recovery flows
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
api-level: 35
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
disable-animations: true
|
||||
emulator-options: >-
|
||||
-no-window -gpu swiftshader_indirect -no-snapshot -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: |
|
||||
python3 scripts/run_maestro.py basic \
|
||||
--skip-build --skip-jellyfin-build \
|
||||
--device emulator-5554 \
|
||||
--fault music-failure \
|
||||
--flow .maestro/real_flows/02_music_browse.yaml \
|
||||
--jellyfin-log build/maestro-recovery/music-jellyfin.log \
|
||||
--proxy-journal build/maestro-recovery/music-proxy-journal.jsonl \
|
||||
--diagnostics-dir build/maestro-recovery/music-diagnostics
|
||||
python3 scripts/run_maestro.py basic \
|
||||
--skip-build --skip-jellyfin-build \
|
||||
--device emulator-5554 \
|
||||
--fault recovery \
|
||||
--flow .maestro/regression_flows/06_playback_recovery.yaml \
|
||||
--jellyfin-log build/maestro-recovery/jellyfin.log \
|
||||
--proxy-journal build/maestro-recovery/proxy-journal.jsonl \
|
||||
--diagnostics-dir build/maestro-recovery/diagnostics
|
||||
|
||||
- name: Upload recovery diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: maestro-android-recovery-diagnostics
|
||||
path: |
|
||||
build/maestro-recovery
|
||||
~/.maestro/tests
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,14 @@
|
||||
flows:
|
||||
- "flows/*.yaml"
|
||||
testOutputDir: "build/maestro"
|
||||
executionOrder:
|
||||
continueOnFailure: false
|
||||
flowsOrder:
|
||||
- Fresh install authentication choices
|
||||
- Jellyfin onboarding reaches Home
|
||||
- Browse library and open media details
|
||||
- Search media and open a result
|
||||
- Start and exit video playback
|
||||
- Open empty downloads state
|
||||
- Manage profiles and open settings
|
||||
- Logout returns to authentication
|
||||
@@ -0,0 +1,33 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Fresh install authentication choices
|
||||
tags:
|
||||
- e2e
|
||||
- auth
|
||||
---
|
||||
- retry:
|
||||
maxRetries: 1
|
||||
commands:
|
||||
- launchApp:
|
||||
clearState: true
|
||||
permissions:
|
||||
all: allow
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Sign in with Plex|Wait).*"
|
||||
timeout: 30000
|
||||
- tapOn:
|
||||
text: "Wait"
|
||||
optional: true
|
||||
- extendedWaitUntil:
|
||||
visible: "Sign in with Plex"
|
||||
timeout: 30000
|
||||
- assertVisible: "Show QR Code"
|
||||
- assertVisible: "Connect to Jellyfin"
|
||||
- tapOn: "Connect to Jellyfin"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add Jellyfin server"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)Server URLs.*"
|
||||
- assertVisible: "Find server"
|
||||
- hideKeyboard
|
||||
- back
|
||||
- assertVisible: "Sign in with Plex"
|
||||
@@ -0,0 +1,13 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Jellyfin onboarding reaches Home
|
||||
tags:
|
||||
- e2e
|
||||
- onboarding
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- assertVisible: "(?s)^Home.*"
|
||||
- assertVisible: "(?s)^Recently Added.*"
|
||||
- assertVisible: "(?s).*Zulu Zone 4.*"
|
||||
- assertVisible: "(?s)^Libraries.*"
|
||||
- assertVisible: "(?s)^Search.*"
|
||||
- assertVisible: "(?s)^Downloads.*"
|
||||
@@ -0,0 +1,22 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Browse library and open media details
|
||||
tags:
|
||||
- e2e
|
||||
- library
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Zulu Zone 4$"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s).*Zulu Zone 4$"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
- assertVisible: "A deterministic title for TV alphabet focus coverage."
|
||||
- assertVisible: "Play"
|
||||
- back
|
||||
- assertVisible: "Maestro Movies"
|
||||
@@ -0,0 +1,24 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Search media and open a result
|
||||
tags:
|
||||
- e2e
|
||||
- search
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- tapOn: "(?s)^Search.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search movies, shows, music..."
|
||||
timeout: 10000
|
||||
- tapOn: "Search movies, shows, music..."
|
||||
- inputText: "Maestro"
|
||||
- hideKeyboard
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movie"
|
||||
timeout: 15000
|
||||
- tapOn: "Maestro Movie"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
- assertVisible: "A deterministic movie used to verify Plezy's end-to-end flows."
|
||||
- back
|
||||
- assertVisible: "(?s)^Maestro Movie.*"
|
||||
@@ -0,0 +1,29 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Start and exit video playback
|
||||
tags:
|
||||
- e2e
|
||||
- playback
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Zulu Zone 4$"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s).*Zulu Zone 4$"
|
||||
- extendedWaitUntil:
|
||||
visible: "Play"
|
||||
timeout: 15000
|
||||
- tapOn:
|
||||
text: "Play"
|
||||
waitToSettleTimeoutMs: 1000
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 20000
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Pause"
|
||||
timeout: 10000
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,11 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Open empty downloads state
|
||||
tags:
|
||||
- e2e
|
||||
- downloads
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- tapOn: "(?s)^Downloads.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "No downloads"
|
||||
timeout: 15000
|
||||
@@ -0,0 +1,62 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Manage profiles and open settings
|
||||
tags:
|
||||
- e2e
|
||||
- profiles
|
||||
- settings
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- tapOn: "M"
|
||||
- tapOn:
|
||||
text: "Profiles"
|
||||
above:
|
||||
text: "Settings"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s).*Maestro.*"
|
||||
- tapOn: "Add Plezy profile"
|
||||
- extendedWaitUntil:
|
||||
visible: "New profile"
|
||||
timeout: 10000
|
||||
- tapOn: "e.g. Guests, Kids, Family Room"
|
||||
- inputText: "E2E Guest"
|
||||
- hideKeyboard
|
||||
- tapOn: "Continue"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add to E2E Guest"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)^Sign in with Plex.*"
|
||||
- assertVisible: "(?s)^Connect to Jellyfin.*"
|
||||
- back
|
||||
- assertVisible: "(?s).*E2E Guest.*"
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Home.*"
|
||||
timeout: 10000
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- repeat:
|
||||
times: 2
|
||||
while:
|
||||
notVisible: "Settings"
|
||||
commands:
|
||||
- tapOn: "M"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- tapOn:
|
||||
text: "Settings"
|
||||
below:
|
||||
text: "Profiles"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Appearance.*"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)^Video Playback.*"
|
||||
- assertVisible: "(?s)^Connections.*"
|
||||
- tapOn: "(?s)^Appearance.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Theme.*"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s).*Library Density.*"
|
||||
- back
|
||||
- assertVisible: "(?s)^Appearance.*"
|
||||
@@ -0,0 +1,23 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Logout returns to authentication
|
||||
tags:
|
||||
- e2e
|
||||
- auth
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- tapOn: "M"
|
||||
- tapOn:
|
||||
text: "Logout"
|
||||
below:
|
||||
text: "Settings"
|
||||
- extendedWaitUntil:
|
||||
visible: "Are you sure you want to logout?"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "Logout"
|
||||
below:
|
||||
text: "Are you sure you want to logout?"
|
||||
- extendedWaitUntil:
|
||||
visible: "Sign in with Plex"
|
||||
timeout: 20000
|
||||
- assertVisible: "Connect to Jellyfin"
|
||||
@@ -0,0 +1,56 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG JELLYFIN_IMAGE=jellyfin/jellyfin:10.11.11@sha256:aefb67e6a7ff1debdd154a78a7bbb780fd0c873d8639210a7f6a2016ad2b35db
|
||||
|
||||
FROM ${JELLYFIN_IMAGE} AS seed
|
||||
|
||||
USER root
|
||||
RUN apt-get update \
|
||||
&& apt-get install --yes --no-install-recommends ca-certificates python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/plezy-demo
|
||||
COPY scripts/maestro_fixtures.py scripts/maestro_real_jellyfin.py scripts/prepare_maestro_media.py ./
|
||||
COPY .maestro/jellyfin-demo/seed.sh ./seed.sh
|
||||
|
||||
ARG PLEZY_DEMO_MEDIA_BASE_URL=https://demo-files.plezy.app/media-samples/
|
||||
ARG PLEZY_DEMO_MEDIA_REVISION=2026-07-14
|
||||
ARG PLEZY_DEMO_MEDIA_DURATION=300
|
||||
RUN test -n "${PLEZY_DEMO_MEDIA_REVISION}" \
|
||||
&& python3 maestro_real_jellyfin.py download-codecs \
|
||||
--base-url "${PLEZY_DEMO_MEDIA_BASE_URL}" \
|
||||
--output-dir /tmp/plezy-codecs \
|
||||
&& PATH="/usr/lib/jellyfin-ffmpeg:${PATH}" python3 prepare_maestro_media.py \
|
||||
/tmp/plezy-codecs /tmp/plezy-codecs-prepared \
|
||||
--duration "${PLEZY_DEMO_MEDIA_DURATION}" \
|
||||
--extend av1_opus_ass_srt.mkv \
|
||||
--extend h264_eac3_multisub.mkv \
|
||||
--extend hevc10_flac_ass.mkv \
|
||||
&& python3 maestro_real_jellyfin.py prepare \
|
||||
--output-dir /media \
|
||||
--codec-source-dir /tmp/plezy-codecs-prepared \
|
||||
--include-codecs \
|
||||
&& rm -rf /tmp/plezy-codecs /tmp/plezy-codecs-prepared __pycache__ \
|
||||
&& chmod -R a=rX /media
|
||||
|
||||
RUN ./seed.sh
|
||||
|
||||
FROM ${JELLYFIN_IMAGE}
|
||||
|
||||
LABEL org.opencontainers.image.title="Plezy Jellyfin demo server" \
|
||||
org.opencontainers.image.description="Ready-to-run Jellyfin server with Plezy's deterministic codec demo catalog" \
|
||||
org.opencontainers.image.source="https://github.com/edde746/plezy"
|
||||
|
||||
COPY --from=seed /media /media
|
||||
COPY --from=seed /opt/plezy-demo/seed-config /opt/plezy-demo/seed-config
|
||||
COPY .maestro/jellyfin-demo/entrypoint.sh /usr/local/bin/plezy-demo-entrypoint
|
||||
|
||||
RUN chmod 0755 /usr/local/bin/plezy-demo-entrypoint \
|
||||
&& chmod -R a=rX /media /opt/plezy-demo/seed-config
|
||||
|
||||
ENV TZ=UTC \
|
||||
JELLYFIN_PublishedServerUrl=http://localhost:8096
|
||||
|
||||
EXPOSE 8096
|
||||
STOPSIGNAL SIGTERM
|
||||
ENTRYPOINT ["/usr/local/bin/plezy-demo-entrypoint"]
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SEED_CONFIG=/opt/plezy-demo/seed-config
|
||||
SEED_MARKER=.plezy-demo-seed
|
||||
|
||||
if [ ! -f "/config/${SEED_MARKER}" ]; then
|
||||
existing="$(find /config -mindepth 1 -maxdepth 1 -print -quit)"
|
||||
if [ -n "${existing}" ]; then
|
||||
echo "Refusing to overwrite a non-demo Jellyfin configuration in /config." >&2
|
||||
echo "Start this image with a new or empty /config volume." >&2
|
||||
exit 1
|
||||
fi
|
||||
cp -a "${SEED_CONFIG}/." /config/
|
||||
fi
|
||||
|
||||
exec /jellyfin/jellyfin "$@"
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SEED_ROOT=/opt/plezy-demo
|
||||
SEED_CONFIG="${SEED_ROOT}/seed-config"
|
||||
SEED_CACHE="${SEED_ROOT}/seed-cache"
|
||||
JELLYFIN_PID=""
|
||||
|
||||
stop_jellyfin() {
|
||||
if [ -n "${JELLYFIN_PID}" ] && kill -0 "${JELLYFIN_PID}" 2>/dev/null; then
|
||||
kill -TERM "${JELLYFIN_PID}"
|
||||
wait "${JELLYFIN_PID}" || true
|
||||
fi
|
||||
}
|
||||
|
||||
on_exit() {
|
||||
exit_status=$?
|
||||
if [ "${exit_status}" -ne 0 ] && [ -f "${SEED_ROOT}/seed-jellyfin.log" ]; then
|
||||
cat "${SEED_ROOT}/seed-jellyfin.log" >&2
|
||||
fi
|
||||
stop_jellyfin
|
||||
}
|
||||
trap on_exit EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
mkdir -p "${SEED_CONFIG}/config" "${SEED_CONFIG}/log" "${SEED_CACHE}"
|
||||
JELLYFIN_DATA_DIR="${SEED_CONFIG}" \
|
||||
JELLYFIN_CONFIG_DIR="${SEED_CONFIG}/config" \
|
||||
JELLYFIN_LOG_DIR="${SEED_CONFIG}/log" \
|
||||
JELLYFIN_CACHE_DIR="${SEED_CACHE}" \
|
||||
XDG_CACHE_HOME="${SEED_CACHE}" \
|
||||
JELLYFIN_PublishedServerUrl="http://127.0.0.1:8096" \
|
||||
/jellyfin/jellyfin >"${SEED_ROOT}/seed-jellyfin.log" 2>&1 &
|
||||
JELLYFIN_PID=$!
|
||||
|
||||
python3 "${SEED_ROOT}/maestro_real_jellyfin.py" bootstrap \
|
||||
--url http://127.0.0.1:8096 \
|
||||
--timeout 180 \
|
||||
--include-codecs
|
||||
|
||||
stop_jellyfin
|
||||
JELLYFIN_PID=""
|
||||
rm -rf "${SEED_CONFIG}/log" "${SEED_CACHE}" "${SEED_ROOT}/seed-jellyfin.log"
|
||||
printf '%s\n' 'Plezy Jellyfin demo seed v1' >"${SEED_CONFIG}/.plezy-demo-seed"
|
||||
trap - EXIT INT TERM
|
||||
@@ -0,0 +1,12 @@
|
||||
flows:
|
||||
- "*.yaml"
|
||||
testOutputDir: "build/maestro-media"
|
||||
executionOrder:
|
||||
continueOnFailure: false
|
||||
flowsOrder:
|
||||
- Codec sample - UHD Dolby Vision TrueHD and PGS
|
||||
- Codec sample - Web Dolby Vision EAC3 Atmos and SRT
|
||||
- Codec sample - AV1 Opus ASS and SRT
|
||||
- Codec sample - H264 High10 DTS-HD and ASS
|
||||
- Codec sample - H264 EAC3 multilingual SRT
|
||||
- Codec sample - HEVC10 FLAC and ASS
|
||||
@@ -0,0 +1,26 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - UHD Dolby Vision TrueHD and PGS
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- dolby-vision
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec DV UHD TrueHD PGS"
|
||||
SAMPLE_OVERVIEW: "Dolby Vision profile 8 with an HDR fallback, TrueHD Atmos, AC-3, and PGS subtitles."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^English.*(?:AC3|TrueHD|Surround|5\\.1|7\\.1).*"
|
||||
SUBTITLE_TRACK: "(?is)^Spanish.*PGS.*"
|
||||
- repeat:
|
||||
times: 2
|
||||
while:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,26 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - Web Dolby Vision EAC3 Atmos and SRT
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- dolby-vision
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec DV Web EAC3 SRT"
|
||||
SAMPLE_OVERVIEW: "Dolby Vision profile 8 with an HDR fallback, E-AC-3 Atmos, and multilingual SRT subtitles."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^German.*(?:E-AC3|Dolby Digital Plus|Surround|5\\.1).*"
|
||||
SUBTITLE_TRACK: "(?is)^German.*(?:SRT|APPLICATION/X-SUBRIP).*"
|
||||
- repeat:
|
||||
times: 2
|
||||
while:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,26 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - AV1 Opus ASS and SRT
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- av1
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec AV1 Opus ASS"
|
||||
SAMPLE_OVERVIEW: "AV1 video with multilingual Opus audio, styled ASS subtitles, SRT subtitles, and embedded fonts."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^English.*(?:Opus|Stereo).*"
|
||||
SUBTITLE_TRACK: "(?is)^English.*(?:ASS|TEXT/X-SSA).*"
|
||||
- repeat:
|
||||
times: 2
|
||||
while:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,26 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - H264 High10 DTS-HD and ASS
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- h264-high10
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec H264 High10 DTS-HD ASS"
|
||||
SAMPLE_OVERVIEW: "H.264 High 10 video with dual DTS-HD MA audio, styled ASS subtitles, and embedded fonts."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^Japanese.*(?:DTS-HD MA|DTS|Surround|5\\.1).*"
|
||||
SUBTITLE_TRACK: "(?is)^English.*(?:ASS|TEXT/X-SSA).*"
|
||||
- repeat:
|
||||
times: 2
|
||||
while:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,27 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - H264 EAC3 multilingual SRT
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- subtitles
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec H264 EAC3 Multisub"
|
||||
SAMPLE_OVERVIEW: "H.264 video with three E-AC-3 audio tracks and a broad multilingual SRT subtitle set."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks_deep.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^English.*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1).*"
|
||||
SUBTITLE_TRACK: "(?is)^English.*APPLICATION/X-SUBRIP.*"
|
||||
SHEET_MARKER: "(?is)^Japanese.*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1).*"
|
||||
- repeat:
|
||||
times: 2
|
||||
while:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,26 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - HEVC10 FLAC and ASS
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- hevc
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec HEVC10 FLAC ASS"
|
||||
SAMPLE_OVERVIEW: "HEVC Main 10 video with dual FLAC 5.1 audio, styled ASS subtitles, and embedded fonts."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^Japanese.*(?:FLAC|Surround|5\\.1).*"
|
||||
SUBTITLE_TRACK: "(?is)^English.*(?:ASS|TEXT/X-SSA).*"
|
||||
- repeat:
|
||||
times: 2
|
||||
while:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,38 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Real Jellyfin imports and opens codec media
|
||||
tags:
|
||||
- e2e
|
||||
- real-jellyfin
|
||||
- media
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- tapOn: "(?s)^Search.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search movies, shows, music..."
|
||||
timeout: 10000
|
||||
- tapOn: "Search movies, shows, music..."
|
||||
- inputText: "Codec DV UHD"
|
||||
- hideKeyboard
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Codec DV UHD TrueHD PGS$"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s).*Codec DV UHD TrueHD PGS$"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
- assertVisible: "Dolby Vision profile 8 with an HDR fallback, TrueHD Atmos, AC-3, and PGS subtitles."
|
||||
- tapOn:
|
||||
text: "Play"
|
||||
waitToSettleTimeoutMs: 1000
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Overview"
|
||||
timeout: 15000
|
||||
- back
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,54 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Jellyfin music browsing survives grouping reloads
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- real-jellyfin
|
||||
- music
|
||||
- regression
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- assertVisible: "(?s)^Latest Albums in Maestro Music.*"
|
||||
- assertVisible: "(?s)^Regression Album.*"
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
- tapOn: "Maestro Movies"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Music"
|
||||
timeout: 10000
|
||||
- tapOn: "Maestro Music"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Regression Album.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s)^Latest Albums in Maestro Music.*"
|
||||
- tapOn: "Browse"
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Retry"
|
||||
commands:
|
||||
- tapOn: "Retry"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Artist"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "(?i).*connection timeout.*"
|
||||
- tapOn: "Library options"
|
||||
- tapOn: "(?s)^Grouping.*Artists$"
|
||||
- tapOn: "Albums"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Regression Album.*"
|
||||
timeout: 15000
|
||||
- tapOn: "Library options"
|
||||
- tapOn: "(?s)^Grouping.*Albums$"
|
||||
- tapOn: "Tracks"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Resilient Track.*"
|
||||
timeout: 15000
|
||||
- tapOn: "Library options"
|
||||
- tapOn: "(?s)^Grouping.*Tracks$"
|
||||
- tapOn: "Folders"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Artist"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "(?i).*connection timeout.*"
|
||||
@@ -0,0 +1,66 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Profile switching isolates Jellyfin content and libraries
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- profiles
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- assertVisible: "(?s).*Zulu Zone 4.*"
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "Guest Movies"
|
||||
- tapOn: "(?s)^Home.*"
|
||||
- runFlow: ../subflows/create_guest_jellyfin_profile.yaml
|
||||
- tapOn: "(?s)^E\nE2E Guest\n.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Guest Galaxy$"
|
||||
timeout: 30000
|
||||
- assertNotVisible: "(?s).*Zulu Zone 4.*"
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Guest Movies"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "Maestro Movies"
|
||||
- tapOn: "(?s)^Home.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Guest Galaxy$"
|
||||
timeout: 15000
|
||||
- tapOn: "E"
|
||||
- tapOn:
|
||||
text: "Profiles"
|
||||
above:
|
||||
text: "Settings"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^M\nMaestro\n.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Zulu Zone 4.*"
|
||||
timeout: 30000
|
||||
- assertNotVisible: "(?s).*Guest Galaxy$"
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "Guest Movies"
|
||||
- tapOn: "(?s)^Home.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Zulu Zone 4.*"
|
||||
timeout: 15000
|
||||
- tapOn: "M"
|
||||
- tapOn:
|
||||
text: "Profiles"
|
||||
above:
|
||||
text: "Settings"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^E\nE2E Guest\n.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Guest Galaxy$"
|
||||
timeout: 30000
|
||||
- assertNotVisible: "(?s).*Zulu Zone 4.*"
|
||||
@@ -0,0 +1,48 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Removing a Jellyfin profile connection leaves no orphaned session
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- profiles
|
||||
- teardown
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- runFlow: ../subflows/create_guest_jellyfin_profile.yaml
|
||||
- tapOn:
|
||||
text: "Manage"
|
||||
index: 1
|
||||
- tapOn: "Manage"
|
||||
- extendedWaitUntil:
|
||||
visible: "Profile name"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)^E2E Guest.*"
|
||||
- assertVisible: "(?s)^Maestro Jellyfin.*Default.*Manage$"
|
||||
- tapOn: "Manage"
|
||||
- extendedWaitUntil:
|
||||
visible: "Remove"
|
||||
timeout: 10000
|
||||
- tapOn: "Remove"
|
||||
- extendedWaitUntil:
|
||||
visible: "^Remove connection\\?$"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s).*E2E Guest's access to Maestro Jellyfin.*"
|
||||
- tapOn: "Remove"
|
||||
- extendedWaitUntil:
|
||||
notVisible: "(?s)^Maestro Jellyfin.*Default.*Manage$"
|
||||
timeout: 15000
|
||||
- assertVisible: "Delete profile"
|
||||
- tapOn: "Delete profile"
|
||||
- extendedWaitUntil:
|
||||
visible: "^Delete profile\\?$"
|
||||
timeout: 10000
|
||||
- tapOn: "^Delete$"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 30000
|
||||
- assertNotVisible: "(?s).*E2E Guest.*"
|
||||
- assertVisible: "(?s)^M\nMaestro\nActive.*"
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Zulu Zone 4.*"
|
||||
timeout: 30000
|
||||
@@ -0,0 +1,68 @@
|
||||
appId: com.edde746.plezy
|
||||
name: TV alphabet rail retains focus until returning to the media grid
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- tv
|
||||
- focus
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Recommended"
|
||||
timeout: 15000
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- pressKey: "Remote Dpad Up"
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- pressKey: "Remote Dpad Up"
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Title"
|
||||
timeout: 10000
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Sort by"
|
||||
timeout: 10000
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 15000
|
||||
- repeat:
|
||||
times: 8
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- repeat:
|
||||
times: 13
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Mike Matrix.*"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "(?s).*Alpha Archive.*"
|
||||
- repeat:
|
||||
times: 13
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Zulu Zone.*"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "(?s).*Mike Matrix.*"
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Play"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s).*Zulu Zone.*"
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
visible: "Browse"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s).*Zulu Zone.*"
|
||||
@@ -0,0 +1,39 @@
|
||||
appId: com.edde746.plezy
|
||||
name: TV hardware media and back keys follow player chrome state
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- tv
|
||||
- playback
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Play"
|
||||
timeout: 15000
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 30000
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Pause"
|
||||
timeout: 15000
|
||||
- pressKey: "Remote Media Play Pause"
|
||||
- extendedWaitUntil:
|
||||
visible: "Play"
|
||||
timeout: 10000
|
||||
- pressKey: "Remote Media Play Pause"
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 10000
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Pause"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "Overview"
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(Play|Resume).*$"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s).*Zulu Zone 4.*"
|
||||
@@ -0,0 +1,43 @@
|
||||
appId: com.edde746.plezy
|
||||
name: TV Back dismisses Next Episode without leaving playback
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- tv
|
||||
- playback
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
|
||||
- tapOn:
|
||||
point: "6%,22%"
|
||||
- tapOn: "Search"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search movies, shows, music..."
|
||||
timeout: 10000
|
||||
- tapOn: "Search movies, shows, music..."
|
||||
- inputText: "Maestro Show"
|
||||
- tapOn:
|
||||
point: "87%,90%"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Maestro Show$"
|
||||
timeout: 15000
|
||||
- tapOn:
|
||||
point: "50%,24%"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- tapOn:
|
||||
point: "15%,76%"
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 30000
|
||||
- pressKey: "Remote Media Fast Forward"
|
||||
- pressKey: "Remote Media Fast Forward"
|
||||
- extendedWaitUntil:
|
||||
visible: "Next Episode"
|
||||
timeout: 20000
|
||||
- pressKey: "back"
|
||||
- assertNotVisible: "Next Episode"
|
||||
- assertNotVisible: "Overview"
|
||||
- pressKey: "Remote Dpad Up"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Maestro Episode 1.*"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,32 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Playback recovers after a transient stream failure
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- playback
|
||||
- recovery
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Zulu Zone 4$"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s).*Zulu Zone 4$"
|
||||
- extendedWaitUntil:
|
||||
visible: "Play"
|
||||
timeout: 15000
|
||||
- tapOn:
|
||||
text: "Play"
|
||||
waitToSettleTimeoutMs: 1000
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 45000
|
||||
- assertNotVisible: "Overview"
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Pause"
|
||||
timeout: 10000
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
@@ -0,0 +1,43 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- tapOn: "M"
|
||||
- tapOn:
|
||||
text: "Profiles"
|
||||
above:
|
||||
text: "Settings"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 10000
|
||||
- tapOn: "Add Plezy profile"
|
||||
- extendedWaitUntil:
|
||||
visible: "New profile"
|
||||
timeout: 10000
|
||||
- tapOn: "e.g. Guests, Kids, Family Room"
|
||||
- inputText: "E2E Guest"
|
||||
- hideKeyboard
|
||||
- tapOn: "Continue"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add to E2E Guest"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^Connect to Jellyfin.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add Jellyfin server"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)Server URLs.*"
|
||||
- inputText: ${JELLYFIN_URL}
|
||||
- hideKeyboard
|
||||
- tapOn: "Find server"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Jellyfin"
|
||||
timeout: 15000
|
||||
- tapOn: "Username"
|
||||
- inputText: "guest"
|
||||
- tapOn: "Password"
|
||||
- inputText: "guest"
|
||||
- hideKeyboard
|
||||
- tapOn: "Sign in"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 30000
|
||||
- assertVisible: "(?s)^M\nMaestro\n.*"
|
||||
- assertVisible: "(?s)^E\nE2E Guest\n.*"
|
||||
@@ -0,0 +1,38 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- retry:
|
||||
maxRetries: 1
|
||||
commands:
|
||||
- launchApp:
|
||||
clearState: true
|
||||
permissions:
|
||||
all: allow
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Connect to Jellyfin|Wait).*"
|
||||
timeout: 30000
|
||||
- tapOn:
|
||||
text: "Wait"
|
||||
optional: true
|
||||
- extendedWaitUntil:
|
||||
visible: "Connect to Jellyfin"
|
||||
timeout: 30000
|
||||
- tapOn: "Connect to Jellyfin"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add Jellyfin server"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)Server URLs.*"
|
||||
- inputText: ${JELLYFIN_URL}
|
||||
- hideKeyboard
|
||||
- tapOn: "Find server"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Jellyfin"
|
||||
timeout: 15000
|
||||
- tapOn: "Username"
|
||||
- inputText: "maestro"
|
||||
- tapOn: "Password"
|
||||
- inputText: "maestro"
|
||||
- hideKeyboard
|
||||
- tapOn: "Sign in"
|
||||
- extendedWaitUntil:
|
||||
visible: "Discover"
|
||||
timeout: 30000
|
||||
@@ -0,0 +1,54 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- retry:
|
||||
maxRetries: 1
|
||||
commands:
|
||||
- launchApp:
|
||||
appId: com.edde746.plezy
|
||||
clearState: true
|
||||
permissions:
|
||||
all: allow
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Connect to Jellyfin|Wait).*"
|
||||
timeout: 30000
|
||||
- tapOn:
|
||||
text: "Wait"
|
||||
optional: true
|
||||
- extendedWaitUntil:
|
||||
visible: "Connect to Jellyfin"
|
||||
timeout: 30000
|
||||
- tapOn: "Connect to Jellyfin"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add Jellyfin server"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)Server URLs.*"
|
||||
- inputText: ${JELLYFIN_URL}
|
||||
- tapOn: "Find server"
|
||||
- extendedWaitUntil:
|
||||
visible: "Username"
|
||||
timeout: 15000
|
||||
- tapOn: "Username"
|
||||
- inputText: "maestro"
|
||||
- tapOn: "Password"
|
||||
- inputText: "maestro"
|
||||
- tapOn: "Sign in"
|
||||
- extendedWaitUntil:
|
||||
visible: "Discover"
|
||||
timeout: 30000
|
||||
- tapOn: "M"
|
||||
- tapOn:
|
||||
text: "Settings"
|
||||
below:
|
||||
text: "Profiles"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Appearance.*"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^Appearance.*"
|
||||
- swipe:
|
||||
start: 50%, 85%
|
||||
end: 50%, 25%
|
||||
duration: 500
|
||||
- tapOn: "(?s)^Force TV mode.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Zulu Zone 4.*"
|
||||
timeout: 30000
|
||||
@@ -0,0 +1,26 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- runFlow: onboard_jellyfin.yaml
|
||||
- tapOn: "(?s)^Search.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search movies, shows, music..."
|
||||
timeout: 10000
|
||||
- tapOn: "Search movies, shows, music..."
|
||||
- inputText: "${SAMPLE_TITLE}"
|
||||
- hideKeyboard
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*${SAMPLE_TITLE}$"
|
||||
timeout: 15000
|
||||
- tapOn:
|
||||
point: "50%,24%"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
- assertVisible: "${SAMPLE_OVERVIEW}"
|
||||
- tapOn:
|
||||
text: "Play"
|
||||
waitToSettleTimeoutMs: 1000
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Overview"
|
||||
timeout: 15000
|
||||
- pressKey: "Remote Media Play Pause"
|
||||
@@ -0,0 +1,21 @@
|
||||
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}"
|
||||
@@ -0,0 +1,27 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- runFlow:
|
||||
file: show_codec_track_sheet.yaml
|
||||
env:
|
||||
SHEET_MARKER: "${AUDIO_TRACK}"
|
||||
- assertVisible: "${AUDIO_TRACK}"
|
||||
- tapOn:
|
||||
point: "30%,70%"
|
||||
- runFlow:
|
||||
file: show_codec_track_sheet.yaml
|
||||
env:
|
||||
SHEET_MARKER: "${AUDIO_TRACK}"
|
||||
- assertVisible: "${SUBTITLE_TRACK}"
|
||||
- tapOn:
|
||||
point: "70%,66%"
|
||||
- runFlow:
|
||||
file: show_codec_track_sheet.yaml
|
||||
env:
|
||||
SHEET_MARKER: "${AUDIO_TRACK}"
|
||||
- assertVisible:
|
||||
text: "${AUDIO_TRACK}"
|
||||
selected: true
|
||||
- assertVisible:
|
||||
text: "${SUBTITLE_TRACK}"
|
||||
selected: true
|
||||
- back
|
||||
@@ -0,0 +1,40 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- runFlow:
|
||||
file: show_codec_track_sheet.yaml
|
||||
env:
|
||||
SHEET_MARKER: "${SHEET_MARKER}"
|
||||
- assertVisible: "${AUDIO_TRACK}"
|
||||
- tapOn:
|
||||
point: "30%,84%"
|
||||
- runFlow:
|
||||
file: show_codec_track_sheet.yaml
|
||||
env:
|
||||
SHEET_MARKER: "${AUDIO_TRACK}"
|
||||
- repeat:
|
||||
times: 12
|
||||
while:
|
||||
notVisible: "${SUBTITLE_TRACK}"
|
||||
commands:
|
||||
- swipe:
|
||||
start: 70%, 82%
|
||||
end: 70%, 68%
|
||||
duration: 300
|
||||
- assertVisible: "${SUBTITLE_TRACK}"
|
||||
- swipe:
|
||||
start: 70%, 78%
|
||||
end: 70%, 68%
|
||||
duration: 300
|
||||
- tapOn:
|
||||
point: "70%,86%"
|
||||
- runFlow:
|
||||
file: show_codec_track_sheet.yaml
|
||||
env:
|
||||
SHEET_MARKER: "${AUDIO_TRACK}"
|
||||
- assertVisible:
|
||||
text: "${AUDIO_TRACK}"
|
||||
selected: true
|
||||
- assertVisible:
|
||||
text: "${SUBTITLE_TRACK}"
|
||||
selected: true
|
||||
- back
|
||||
@@ -38,6 +38,34 @@ The project includes automated CI checks that run on all pull requests:
|
||||
|
||||
All these checks must pass before your changes can be merged.
|
||||
|
||||
### Maestro end-to-end tests
|
||||
|
||||
Android E2E tests use [Maestro](https://maestro.mobile.dev/) against a disposable, pre-seeded Jellyfin container.
|
||||
|
||||
Prerequisites: Java 17, Flutter and Android SDK/platform tools, a running Android emulator, Docker, and the
|
||||
[Maestro CLI](https://docs.maestro.dev/getting-started/installing-maestro).
|
||||
|
||||
Run the suites from the repository root (`py -3` can replace `python3` on Windows):
|
||||
|
||||
```bash
|
||||
python3 scripts/run_maestro.py basic # Basic user flows
|
||||
python3 scripts/run_maestro.py catalog # Catalog and music flows
|
||||
python3 scripts/run_maestro.py media # Codec playback and track selection
|
||||
```
|
||||
|
||||
Run one flow with `--flow`:
|
||||
|
||||
```bash
|
||||
python3 scripts/run_maestro.py basic --flow .maestro/flows/04_search.yaml
|
||||
```
|
||||
|
||||
Use `--skip-build` to reuse the debug APK and `--skip-jellyfin-build` to reuse the Jellyfin image. Set
|
||||
`--device <adb-serial>` when multiple devices are connected; physical devices also require `--adb-reverse`.
|
||||
|
||||
Top-level flows live in `.maestro/flows/`, shared setup in `.maestro/subflows/`, and focused regressions in
|
||||
`.maestro/regression_flows/`. CI runs the same suites from `.github/workflows/e2e.yml` and uploads diagnostics on
|
||||
failure.
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
This project uses `slang` for internationalization with JSON files.
|
||||
|
||||
+2
-2
@@ -112,8 +112,8 @@ void _registerTvosPlatformPlugins() {
|
||||
|
||||
Future<void> main() async {
|
||||
final binding = WidgetsFlutterBinding.ensureInitialized();
|
||||
// Build the semantics tree in debug so Maestro/UI automation can locate
|
||||
// widgets by text. Zero cost in release builds.
|
||||
// Keep the accessibility tree available to Maestro and other UI automation
|
||||
// without adding release-build overhead.
|
||||
if (kDebugMode) binding.ensureSemantics();
|
||||
_installZeroOffsetPointerGuard(); // Workaround for iPadOS 26.1+ modal dismissal bug
|
||||
|
||||
|
||||
@@ -6,9 +6,12 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
final tvScale = TvLayoutConstants.scaleOf(context);
|
||||
final actionSize = isTv ? _tvDetailActionSize * tvScale : 48.0;
|
||||
final playButtonLabel = _getPlayButtonLabel(metadata);
|
||||
final playIcon = _getPlayButtonIcon(metadata);
|
||||
final playActionLabel = playIcon == Symbols.resume_rounded ? t.common.resume : t.common.play;
|
||||
final playSemanticsLabel = playButtonLabel.isEmpty ? playActionLabel : '$playActionLabel $playButtonLabel';
|
||||
final playIconSize = isTv ? 22 * tvScale : 20.0;
|
||||
final playTextStyle = TextStyle(fontSize: isTv ? 17 * tvScale : 16, fontWeight: .w700);
|
||||
final playButtonIcon = AppIcon(_getPlayButtonIcon(metadata), fill: 1, size: playIconSize);
|
||||
final playButtonIcon = AppIcon(playIcon, fill: 1, size: playIconSize);
|
||||
|
||||
Future<void> onPlayPressed() async {
|
||||
// For TV shows, play the OnDeck episode if available
|
||||
@@ -102,7 +105,12 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
final gap = isTv ? 8.0 * tvScale : 12.0;
|
||||
|
||||
Widget playButton(FocusableActionBuildState state) {
|
||||
return SizedBox(
|
||||
return Semantics(
|
||||
label: playSemanticsLabel,
|
||||
button: true,
|
||||
onTap: onPlayPressed,
|
||||
excludeSemantics: true,
|
||||
child: SizedBox(
|
||||
height: actionSize,
|
||||
child: FilledButton(
|
||||
onPressed: onPlayPressed,
|
||||
@@ -121,6 +129,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
)
|
||||
: playButtonIcon,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ class TrackSelectionHelper {
|
||||
Widget tile = FocusableListTile(
|
||||
key: key,
|
||||
focusNode: focusNode,
|
||||
selected: isSelected,
|
||||
title: Text(
|
||||
label,
|
||||
style: TextStyle(color: isSelected ? primaryColor : null),
|
||||
|
||||
@@ -86,6 +86,7 @@ if python3 scripts/check_build_workflow.py &&
|
||||
python3 scripts/check_update_packages_workflow.py &&
|
||||
python3 scripts/test_pubspec_version.py &&
|
||||
python3 scripts/test_clean_translations.py &&
|
||||
python3 scripts/test_run_maestro.py &&
|
||||
python3 scripts/test_check_icon_consistency.py; then
|
||||
ok "workflow and script guards passed"
|
||||
else
|
||||
|
||||
File diff suppressed because one or more lines are too long
Executable
+194
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Forward to a real Jellyfin server with narrowly scoped one-shot faults."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
_FAULT_PATHS = {
|
||||
"music-failure": lambda path: path.startswith("/Artists/AlbumArtists"),
|
||||
"recovery": lambda path: path.startswith("/Videos/") and "/stream" in path,
|
||||
}
|
||||
_FORWARD_HEADERS = {
|
||||
"accept",
|
||||
"authorization",
|
||||
"content-type",
|
||||
"if-modified-since",
|
||||
"if-none-match",
|
||||
"range",
|
||||
"user-agent",
|
||||
"x-emby-authorization",
|
||||
"x-emby-token",
|
||||
}
|
||||
_RESPONSE_HEADERS = {
|
||||
"accept-ranges",
|
||||
"cache-control",
|
||||
"content-disposition",
|
||||
"content-range",
|
||||
"content-type",
|
||||
"date",
|
||||
"etag",
|
||||
"last-modified",
|
||||
"location",
|
||||
}
|
||||
|
||||
|
||||
class ProxyState:
|
||||
def __init__(self, upstream: str, fault: str | None, journal: Path | None) -> None:
|
||||
self.upstream = upstream.rstrip("/")
|
||||
self.fault = fault
|
||||
self.journal = journal
|
||||
self._fault_injected = False
|
||||
self._sequence = 0
|
||||
self._lock = threading.Lock()
|
||||
if journal is not None:
|
||||
journal.parent.mkdir(parents=True, exist_ok=True)
|
||||
journal.write_text("", encoding="utf-8")
|
||||
|
||||
def should_fault(self, path: str) -> bool:
|
||||
predicate = _FAULT_PATHS.get(self.fault)
|
||||
if predicate is None or not predicate(path):
|
||||
return False
|
||||
with self._lock:
|
||||
if self._fault_injected:
|
||||
return False
|
||||
self._fault_injected = True
|
||||
return True
|
||||
|
||||
def record(self, *, method: str, path: str, status: int, kind: str) -> None:
|
||||
if self.journal is None:
|
||||
return
|
||||
with self._lock:
|
||||
self._sequence += 1
|
||||
event = {
|
||||
"sequence": self._sequence,
|
||||
"timestampMs": int(time.time() * 1000),
|
||||
"kind": kind,
|
||||
"method": method,
|
||||
"path": urllib.parse.urlsplit(path).path,
|
||||
"status": status,
|
||||
}
|
||||
with self.journal.open("a", encoding="utf-8") as output:
|
||||
output.write(json.dumps(event, separators=(",", ":"), sort_keys=True) + "\n")
|
||||
|
||||
|
||||
class JellyfinProxyHandler(BaseHTTPRequestHandler):
|
||||
server_version = "PlezyJellyfinProxy/1.0"
|
||||
|
||||
@property
|
||||
def state(self) -> ProxyState:
|
||||
return self.server.state # type: ignore[attr-defined]
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self._proxy()
|
||||
|
||||
def do_HEAD(self) -> None:
|
||||
self._proxy()
|
||||
|
||||
def do_POST(self) -> None:
|
||||
self._proxy()
|
||||
|
||||
def do_PUT(self) -> None:
|
||||
self._proxy()
|
||||
|
||||
def do_PATCH(self) -> None:
|
||||
self._proxy()
|
||||
|
||||
def do_DELETE(self) -> None:
|
||||
self._proxy()
|
||||
|
||||
def do_OPTIONS(self) -> None:
|
||||
self._proxy()
|
||||
|
||||
def _proxy(self) -> None:
|
||||
if self.state.should_fault(self.path):
|
||||
payload = json.dumps({"error": "temporary Maestro fault"}).encode("utf-8")
|
||||
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)
|
||||
self.state.record(method=self.command, path=self.path, status=503, kind="fault")
|
||||
return
|
||||
|
||||
content_length = int(self.headers.get("Content-Length", "0"))
|
||||
body = self.rfile.read(content_length) if content_length else None
|
||||
headers = {
|
||||
name: value
|
||||
for name, value in self.headers.items()
|
||||
if name.lower() in _FORWARD_HEADERS
|
||||
}
|
||||
request = urllib.request.Request(
|
||||
self.state.upstream + self.path,
|
||||
data=body,
|
||||
method=self.command,
|
||||
headers=headers,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
status = response.status
|
||||
response_headers = response.headers
|
||||
payload = response.read()
|
||||
except urllib.error.HTTPError as error:
|
||||
status = error.code
|
||||
response_headers = error.headers
|
||||
payload = error.read()
|
||||
except (OSError, urllib.error.URLError) as error:
|
||||
payload = json.dumps({"error": f"upstream unavailable: {error}"}).encode("utf-8")
|
||||
status = HTTPStatus.BAD_GATEWAY
|
||||
response_headers = {"Content-Type": "application/json"}
|
||||
|
||||
self.send_response(status)
|
||||
for name, value in response_headers.items():
|
||||
if name.lower() in _RESPONSE_HEADERS:
|
||||
self.send_header(name, value)
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
try:
|
||||
self.wfile.write(payload)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
self.state.record(method=self.command, path=self.path, status=int(status), kind="request")
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
print(f"jellyfin-proxy: {format % args}")
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, required=True)
|
||||
parser.add_argument("--upstream", required=True)
|
||||
parser.add_argument("--fault", choices=sorted(_FAULT_PATHS))
|
||||
parser.add_argument("--journal", type=Path)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = _build_parser().parse_args()
|
||||
server = ThreadingHTTPServer((args.host, args.port), JellyfinProxyHandler)
|
||||
server.daemon_threads = True
|
||||
server.state = ProxyState(args.upstream, args.fault, args.journal) # type: ignore[attr-defined]
|
||||
print(f"Jellyfin proxy listening on http://{args.host}:{args.port} -> {args.upstream}", flush=True)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+687
@@ -0,0 +1,687 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare and bootstrap a disposable real Jellyfin server for Maestro."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from maestro_fixtures import MEDIA_FIXTURE_SPECS, _AUDIO, _VIDEO
|
||||
|
||||
USERNAME = "maestro"
|
||||
PASSWORD = "maestro"
|
||||
GUEST_USERNAME = "guest"
|
||||
GUEST_PASSWORD = "guest"
|
||||
SERVER_NAME = "Maestro Jellyfin"
|
||||
BASE_TITLE = "Maestro Movie"
|
||||
BASE_OVERVIEW = "A deterministic movie used to verify Plezy's end-to-end flows."
|
||||
GUEST_TITLE = "Guest Galaxy"
|
||||
SHOW_TITLE = "Maestro Show"
|
||||
EPISODE_TITLES = ("Maestro Episode 1", "Maestro Episode 2")
|
||||
MUSIC_ARTIST = "Maestro Artist"
|
||||
MUSIC_ALBUM = "Regression Album"
|
||||
MUSIC_TRACK = "Resilient Track"
|
||||
ALPHABET_TITLES = (
|
||||
"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",
|
||||
)
|
||||
_MANAGED_MARKER = ".plezy-jellyfin-e2e-media"
|
||||
DEFAULT_CODEC_BASE_URL = "https://demo-files.plezy.app/media-samples/"
|
||||
_DOWNLOAD_CHUNK_SIZE = 1024 * 1024
|
||||
|
||||
|
||||
|
||||
def _write_nfo(path: Path, *, item_id: str, title: str, overview: str, genre: str) -> None:
|
||||
movie = ET.Element("movie")
|
||||
values = {
|
||||
"title": title,
|
||||
"originaltitle": title,
|
||||
"sorttitle": title,
|
||||
"year": "2026",
|
||||
"premiered": "2026-01-01",
|
||||
"dateadded": "2026-01-01 00:00:00",
|
||||
"plot": overview,
|
||||
"outline": overview,
|
||||
"studio": "Plezy E2E",
|
||||
"genre": genre,
|
||||
"tag": "E2E",
|
||||
"mpaa": "E2E",
|
||||
"rating": "8.0",
|
||||
"lockdata": "true",
|
||||
}
|
||||
for key, value in values.items():
|
||||
ET.SubElement(movie, key).text = value
|
||||
unique_id = ET.SubElement(movie, "uniqueid", {"type": "plezy", "default": "true"})
|
||||
unique_id.text = item_id
|
||||
ET.indent(movie, space=" ")
|
||||
ET.ElementTree(movie).write(path, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
def _write_show_nfo(path: Path) -> None:
|
||||
show = ET.Element("tvshow")
|
||||
for key, value in {
|
||||
"title": SHOW_TITLE,
|
||||
"sorttitle": SHOW_TITLE,
|
||||
"year": "2026",
|
||||
"premiered": "2026-01-01",
|
||||
"plot": "A deterministic show used to verify episode playback and queue behavior.",
|
||||
"studio": "Plezy E2E",
|
||||
"genre": "Test",
|
||||
"lockdata": "true",
|
||||
}.items():
|
||||
ET.SubElement(show, key).text = value
|
||||
unique_id = ET.SubElement(show, "uniqueid", {"type": "plezy", "default": "true"})
|
||||
unique_id.text = "maestro-show"
|
||||
ET.indent(show, space=" ")
|
||||
ET.ElementTree(show).write(path, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def _write_episode_nfo(path: Path, number: int) -> None:
|
||||
episode = ET.Element("episodedetails")
|
||||
title = EPISODE_TITLES[number - 1]
|
||||
for key, value in {
|
||||
"title": title,
|
||||
"showtitle": SHOW_TITLE,
|
||||
"season": "1",
|
||||
"episode": str(number),
|
||||
"aired": f"2026-01-0{number}",
|
||||
"plot": f"Deterministic episode {number} for player queue coverage.",
|
||||
"lockdata": "true",
|
||||
}.items():
|
||||
ET.SubElement(episode, key).text = value
|
||||
unique_id = ET.SubElement(episode, "uniqueid", {"type": "plezy", "default": "true"})
|
||||
unique_id.text = f"maestro-episode-{number}"
|
||||
ET.indent(episode, space=" ")
|
||||
ET.ElementTree(episode).write(path, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def _write_music_nfo(path: Path, root_name: str, values: dict[str, str]) -> None:
|
||||
root = ET.Element(root_name)
|
||||
for key, value in values.items():
|
||||
ET.SubElement(root, key).text = value
|
||||
ET.indent(root, space=" ")
|
||||
ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def _reset_managed_directory(path: Path) -> None:
|
||||
marker = path / _MANAGED_MARKER
|
||||
if not path.exists():
|
||||
path.mkdir(parents=True)
|
||||
marker.write_text("Managed by scripts/maestro_real_jellyfin.py\n", encoding="utf-8")
|
||||
return
|
||||
if not marker.is_file():
|
||||
if any(path.iterdir()):
|
||||
raise ValueError(f"Refusing to clear unmanaged media staging directory: {path}")
|
||||
marker.write_text("Managed by scripts/maestro_real_jellyfin.py\n", encoding="utf-8")
|
||||
return
|
||||
for child in path.iterdir():
|
||||
if child == marker:
|
||||
continue
|
||||
if child.is_dir() and not child.is_symlink():
|
||||
shutil.rmtree(child)
|
||||
else:
|
||||
child.unlink()
|
||||
|
||||
|
||||
def _hard_link(source: Path, destination: Path) -> None:
|
||||
try:
|
||||
os.link(source, destination)
|
||||
except OSError as error:
|
||||
raise ValueError(
|
||||
f"Could not hard-link {source} into the staging directory; keep both paths on the same filesystem"
|
||||
) from error
|
||||
|
||||
|
||||
def _codec_digest(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
while chunk := source.read(_DOWNLOAD_CHUNK_SIZE):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _valid_codec_file(path: Path, expected_size: int, expected_sha256: str) -> bool:
|
||||
return path.is_file() and path.stat().st_size == expected_size and _codec_digest(path) == expected_sha256
|
||||
|
||||
|
||||
def download_codec_media(output_dir: Path, base_url: str = DEFAULT_CODEC_BASE_URL) -> list[str]:
|
||||
output = output_dir.expanduser().resolve()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
normalized_base_url = base_url.rstrip("/") + "/"
|
||||
filenames: list[str] = []
|
||||
|
||||
for spec in MEDIA_FIXTURE_SPECS:
|
||||
destination = output / spec.filename
|
||||
filenames.append(spec.filename)
|
||||
if _valid_codec_file(destination, spec.size_bytes, spec.sha256):
|
||||
continue
|
||||
|
||||
partial = destination.with_suffix(f"{destination.suffix}.part")
|
||||
partial.unlink(missing_ok=True)
|
||||
request = urllib.request.Request(
|
||||
urllib.parse.urljoin(normalized_base_url, urllib.parse.quote(spec.filename)),
|
||||
headers={"User-Agent": "plezy-jellyfin-demo-builder"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
raw_length = response.headers.get("Content-Length")
|
||||
if raw_length is not None and int(raw_length) != spec.size_bytes:
|
||||
raise ValueError(
|
||||
f"{spec.filename} download is {raw_length} bytes, expected {spec.size_bytes}"
|
||||
)
|
||||
total = 0
|
||||
digest = hashlib.sha256()
|
||||
with partial.open("wb") as target:
|
||||
while chunk := response.read(_DOWNLOAD_CHUNK_SIZE):
|
||||
total += len(chunk)
|
||||
if total > spec.size_bytes:
|
||||
raise ValueError(f"{spec.filename} download exceeds {spec.size_bytes} bytes")
|
||||
digest.update(chunk)
|
||||
target.write(chunk)
|
||||
if total != spec.size_bytes:
|
||||
raise ValueError(f"{spec.filename} download is {total} bytes, expected {spec.size_bytes}")
|
||||
if digest.hexdigest() != spec.sha256:
|
||||
raise ValueError(f"{spec.filename} download failed SHA-256 verification")
|
||||
partial.replace(destination)
|
||||
except (OSError, ValueError, urllib.error.URLError):
|
||||
partial.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
return filenames
|
||||
|
||||
|
||||
def prepare_media(output_dir: Path, codec_source_dir: Path | None, include_codecs: bool) -> list[str]:
|
||||
output = output_dir.expanduser().resolve()
|
||||
_reset_managed_directory(output)
|
||||
|
||||
base_dir = output / "movies" / "maestro-movie"
|
||||
base_dir.mkdir(parents=True)
|
||||
(base_dir / f"{BASE_TITLE}.mp4").write_bytes(_VIDEO)
|
||||
_write_nfo(
|
||||
base_dir / f"{BASE_TITLE}.nfo",
|
||||
item_id="maestro-movie",
|
||||
title=BASE_TITLE,
|
||||
overview=BASE_OVERVIEW,
|
||||
genre="Test",
|
||||
)
|
||||
titles = [BASE_TITLE]
|
||||
|
||||
for alphabet_title in ALPHABET_TITLES:
|
||||
for copy in range(1, 5):
|
||||
title = alphabet_title if copy == 1 else f"{alphabet_title} {copy}"
|
||||
item_id = f"alpha-{title.lower().replace(' ', '-')}"
|
||||
item_dir = output / "movies" / item_id
|
||||
item_dir.mkdir()
|
||||
media_path = item_dir / f"{title}.mp4"
|
||||
media_path.write_bytes(_VIDEO)
|
||||
_write_nfo(
|
||||
media_path.with_suffix(".nfo"),
|
||||
item_id=item_id,
|
||||
title=title,
|
||||
overview="A deterministic title for TV alphabet focus coverage.",
|
||||
genre="E2E Alphabet",
|
||||
)
|
||||
titles.append(title)
|
||||
|
||||
guest_dir = output / "guest-movies" / "guest-galaxy"
|
||||
guest_dir.mkdir(parents=True)
|
||||
guest_media = guest_dir / f"{GUEST_TITLE}.mp4"
|
||||
guest_media.write_bytes(_VIDEO)
|
||||
_write_nfo(
|
||||
guest_media.with_suffix(".nfo"),
|
||||
item_id="guest-galaxy",
|
||||
title=GUEST_TITLE,
|
||||
overview="Content visible only to the Maestro Guest profile.",
|
||||
genre="E2E Guest",
|
||||
)
|
||||
|
||||
show_dir = output / "shows" / SHOW_TITLE
|
||||
season_dir = show_dir / "Season 01"
|
||||
season_dir.mkdir(parents=True)
|
||||
_write_show_nfo(show_dir / "tvshow.nfo")
|
||||
for number, title in enumerate(EPISODE_TITLES, start=1):
|
||||
episode_path = season_dir / f"{SHOW_TITLE} S01E{number:02d} - {title}.mp4"
|
||||
episode_path.write_bytes(_VIDEO)
|
||||
_write_episode_nfo(episode_path.with_suffix(".nfo"), number)
|
||||
|
||||
album_dir = output / "music" / MUSIC_ARTIST / MUSIC_ALBUM
|
||||
album_dir.mkdir(parents=True)
|
||||
(album_dir / f"{MUSIC_TRACK}.wav").write_bytes(_AUDIO)
|
||||
_write_music_nfo(
|
||||
album_dir.parent / "artist.nfo",
|
||||
"artist",
|
||||
{"name": MUSIC_ARTIST, "sortname": MUSIC_ARTIST, "overview": "Deterministic E2E music artist."},
|
||||
)
|
||||
_write_music_nfo(
|
||||
album_dir / "album.nfo",
|
||||
"album",
|
||||
{
|
||||
"title": MUSIC_ALBUM,
|
||||
"artist": MUSIC_ARTIST,
|
||||
"albumartist": MUSIC_ARTIST,
|
||||
"year": "2026",
|
||||
"review": "Deterministic E2E music album.",
|
||||
},
|
||||
)
|
||||
|
||||
if not include_codecs:
|
||||
return titles
|
||||
if codec_source_dir is None:
|
||||
raise ValueError("--codec-source-dir is required with --include-codecs")
|
||||
|
||||
source_dir = codec_source_dir.expanduser().resolve()
|
||||
missing = [spec.filename for spec in MEDIA_FIXTURE_SPECS if not (source_dir / spec.filename).is_file()]
|
||||
if missing:
|
||||
raise ValueError(f"Codec fixture directory is missing: {', '.join(missing)}")
|
||||
|
||||
for spec in MEDIA_FIXTURE_SPECS:
|
||||
item_dir = output / "movies" / spec.id
|
||||
item_dir.mkdir()
|
||||
media_path = item_dir / f"{spec.title}.mkv"
|
||||
_hard_link(source_dir / spec.filename, media_path)
|
||||
_write_nfo(
|
||||
media_path.with_suffix(".nfo"),
|
||||
item_id=spec.id,
|
||||
title=spec.title,
|
||||
overview=spec.overview,
|
||||
genre="E2E Codec",
|
||||
)
|
||||
titles.append(spec.title)
|
||||
return titles
|
||||
|
||||
|
||||
class JellyfinApi:
|
||||
def __init__(self, base_url: str) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
payload: Any | None = None,
|
||||
token: str | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: float = 30,
|
||||
) -> tuple[int, bytes]:
|
||||
request_headers = {"Accept": "application/json", **(headers or {})}
|
||||
data: bytes | None = None
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
request_headers["Content-Type"] = "application/json"
|
||||
elif method == "POST":
|
||||
data = b""
|
||||
if token is not None:
|
||||
request_headers["X-Emby-Token"] = token
|
||||
request = urllib.request.Request(self.base_url + path, data=data, method=method, headers=request_headers)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return response.status, response.read()
|
||||
except urllib.error.HTTPError as error:
|
||||
return error.code, error.read()
|
||||
|
||||
def json(self, method: str, path: str, **kwargs: Any) -> Any:
|
||||
status, body = self.request(method, path, **kwargs)
|
||||
if status < 200 or status >= 300:
|
||||
detail = body.decode("utf-8", errors="replace")[:500]
|
||||
raise RuntimeError(f"Jellyfin {method} {path} returned HTTP {status}: {detail}")
|
||||
return json.loads(body) if body else None
|
||||
|
||||
|
||||
def _wait_until(deadline: float, description: str, operation: Any) -> Any:
|
||||
last_error: Exception | None = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
result = operation()
|
||||
if result is not None:
|
||||
return result
|
||||
except (OSError, RuntimeError, urllib.error.URLError) as error:
|
||||
last_error = error
|
||||
time.sleep(1)
|
||||
suffix = f": {last_error}" if last_error is not None else ""
|
||||
raise TimeoutError(f"Timed out waiting for {description}{suffix}")
|
||||
|
||||
|
||||
def bootstrap_server(
|
||||
base_url: str,
|
||||
expected_titles: set[str],
|
||||
timeout_seconds: int,
|
||||
expected_music_titles: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
api = JellyfinApi(base_url)
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
|
||||
def public_info() -> dict[str, Any] | None:
|
||||
status, body = api.request("GET", "/System/Info/Public", timeout=3)
|
||||
if status != 200:
|
||||
return None
|
||||
return json.loads(body)
|
||||
|
||||
info = _wait_until(deadline, "Jellyfin startup", public_info)
|
||||
version = info.get("Version") or info.get("version")
|
||||
if version != "10.11.11":
|
||||
raise RuntimeError(f"Expected Jellyfin 10.11.11, got {version}")
|
||||
|
||||
startup_complete = info.get("StartupWizardCompleted", info.get("startupWizardCompleted", False))
|
||||
if not startup_complete:
|
||||
|
||||
def configure_startup() -> bool | None:
|
||||
status, body = api.request(
|
||||
"POST",
|
||||
"/Startup/Configuration",
|
||||
payload={
|
||||
"UICulture": "en-US",
|
||||
"MetadataCountryCode": "US",
|
||||
"PreferredMetadataLanguage": "en",
|
||||
},
|
||||
)
|
||||
if status == 204:
|
||||
return True
|
||||
if status == 503:
|
||||
return None
|
||||
detail = body.decode("utf-8", errors="replace")[:500]
|
||||
raise RuntimeError(f"Jellyfin startup configuration returned HTTP {status}: {detail}")
|
||||
|
||||
_wait_until(deadline, "Jellyfin startup API", configure_startup)
|
||||
|
||||
def startup_user() -> dict[str, Any] | None:
|
||||
status, body = api.request("GET", "/Startup/User", timeout=3)
|
||||
if status != 200:
|
||||
return None
|
||||
return json.loads(body)
|
||||
|
||||
_wait_until(deadline, "Jellyfin startup user", startup_user)
|
||||
api.json("POST", "/Startup/User", payload={"Name": USERNAME, "Password": PASSWORD})
|
||||
api.json("POST", "/Startup/Complete")
|
||||
|
||||
authorization = (
|
||||
'MediaBrowser Client="Plezy E2E Bootstrap", Device="Host", '
|
||||
'DeviceId="plezy-e2e-bootstrap", Version="1.0"'
|
||||
)
|
||||
|
||||
def authenticate(username: str, password: str) -> dict[str, Any] | None:
|
||||
status, body = api.request(
|
||||
"POST",
|
||||
"/Users/AuthenticateByName",
|
||||
payload={"Username": username, "Pw": password},
|
||||
headers={"Authorization": authorization},
|
||||
timeout=5,
|
||||
)
|
||||
if status != 200:
|
||||
return None
|
||||
return json.loads(body)
|
||||
|
||||
authentication = _wait_until(deadline, "Jellyfin authentication", lambda: authenticate(USERNAME, PASSWORD))
|
||||
token = authentication["AccessToken"]
|
||||
user_id = authentication["User"]["Id"]
|
||||
|
||||
configuration = api.json("GET", "/System/Configuration", token=token)
|
||||
if configuration.get("ServerName") != SERVER_NAME:
|
||||
configuration["ServerName"] = SERVER_NAME
|
||||
api.json("POST", "/System/Configuration", payload=configuration, token=token)
|
||||
|
||||
required_folders = (
|
||||
("Maestro Movies", "movies", "/media/movies"),
|
||||
("Guest Movies", "movies", "/media/guest-movies"),
|
||||
("Maestro Shows", "tvshows", "/media/shows"),
|
||||
("Maestro Music", "music", "/media/music"),
|
||||
)
|
||||
virtual_folders = api.json("GET", "/Library/VirtualFolders", token=token)
|
||||
existing_folder_names = {folder.get("Name") for folder in virtual_folders}
|
||||
for name, collection_type, path in required_folders:
|
||||
if name in existing_folder_names:
|
||||
continue
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
"name": name,
|
||||
"collectionType": collection_type,
|
||||
"paths": path,
|
||||
"refreshLibrary": "false",
|
||||
}
|
||||
)
|
||||
api.json("POST", f"/Library/VirtualFolders?{query}", token=token)
|
||||
|
||||
users = api.json("GET", "/Users", token=token)
|
||||
main_user = next(user for user in users if user["Id"] == user_id)
|
||||
guest_user = next((user for user in users if user.get("Name") == GUEST_USERNAME), None)
|
||||
if guest_user is None:
|
||||
guest_user = api.json(
|
||||
"POST",
|
||||
"/Users/New",
|
||||
payload={"Name": GUEST_USERNAME, "Password": GUEST_PASSWORD},
|
||||
token=token,
|
||||
)
|
||||
if not guest_user.get("HasPassword", guest_user.get("HasConfiguredPassword", False)):
|
||||
api.json(
|
||||
"POST",
|
||||
f"/Users/{guest_user['Id']}/Password",
|
||||
payload={"CurrentPw": "", "NewPw": GUEST_PASSWORD},
|
||||
token=token,
|
||||
)
|
||||
|
||||
unrestricted_policy = dict(main_user["Policy"])
|
||||
unrestricted_policy["EnableAllFolders"] = True
|
||||
unrestricted_policy["EnabledFolders"] = []
|
||||
api.json("POST", f"/Users/{user_id}/Policy", payload=unrestricted_policy, token=token)
|
||||
|
||||
views = api.json("GET", f"/Users/{user_id}/Views", token=token).get("Items", [])
|
||||
views_by_name = {view.get("Name"): view.get("Id") for view in views}
|
||||
missing_views = [name for name, _, _ in required_folders if not views_by_name.get(name)]
|
||||
if missing_views:
|
||||
raise RuntimeError(f"Jellyfin did not create library views: {', '.join(missing_views)}")
|
||||
|
||||
main_policy = dict(main_user["Policy"])
|
||||
main_policy["EnableAllFolders"] = False
|
||||
main_policy["EnabledFolders"] = [
|
||||
views_by_name["Maestro Movies"],
|
||||
views_by_name["Maestro Shows"],
|
||||
views_by_name["Maestro Music"],
|
||||
]
|
||||
api.json("POST", f"/Users/{user_id}/Policy", payload=main_policy, token=token)
|
||||
|
||||
guest_policy = dict(guest_user["Policy"])
|
||||
guest_policy["EnableAllFolders"] = False
|
||||
guest_policy["EnabledFolders"] = [views_by_name["Guest Movies"]]
|
||||
api.json("POST", f"/Users/{guest_user['Id']}/Policy", payload=guest_policy, token=token)
|
||||
|
||||
api.json("POST", "/Library/Refresh", token=token)
|
||||
|
||||
def scanned_items() -> list[dict[str, Any]] | None:
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
"Recursive": "true",
|
||||
"IncludeItemTypes": "Movie",
|
||||
"Fields": "MediaSources,MediaStreams",
|
||||
"Limit": "500",
|
||||
}
|
||||
)
|
||||
result = api.json("GET", f"/Users/{user_id}/Items?{query}", token=token)
|
||||
items = result.get("Items", [])
|
||||
by_title = {item.get("Name"): item for item in items}
|
||||
if not expected_titles.issubset(by_title):
|
||||
return None
|
||||
if any(not by_title[title].get("MediaSources") for title in expected_titles):
|
||||
return None
|
||||
if GUEST_TITLE in by_title:
|
||||
raise RuntimeError("Main Jellyfin user can see the guest-only library")
|
||||
return items
|
||||
|
||||
items = _wait_until(deadline, "Jellyfin media scan", scanned_items)
|
||||
required_music = expected_music_titles or {MUSIC_TRACK}
|
||||
|
||||
def scanned_music() -> list[dict[str, Any]] | None:
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
"Recursive": "true",
|
||||
"IncludeItemTypes": "Audio",
|
||||
"Fields": "MediaSources,MediaStreams,Album,AlbumId,AlbumArtist,AlbumArtists",
|
||||
"Limit": "100",
|
||||
}
|
||||
)
|
||||
result = api.json("GET", f"/Users/{user_id}/Items?{query}", token=token)
|
||||
music_items = result.get("Items", [])
|
||||
by_title = {item.get("Name"): item for item in music_items}
|
||||
if not required_music.issubset(by_title):
|
||||
return None
|
||||
if any(not by_title[title].get("MediaSources") for title in required_music):
|
||||
return None
|
||||
return music_items
|
||||
|
||||
music_items = _wait_until(deadline, "Jellyfin music scan", scanned_music)
|
||||
|
||||
def scanned_artists() -> list[dict[str, Any]] | None:
|
||||
query = urllib.parse.urlencode({"UserId": user_id, "Limit": "100"})
|
||||
result = api.json("GET", f"/Artists/AlbumArtists?{query}", token=token)
|
||||
artists = result.get("Items", [])
|
||||
if MUSIC_ARTIST not in {artist.get("Name") for artist in artists}:
|
||||
return None
|
||||
return artists
|
||||
|
||||
artist_items = _wait_until(deadline, "Jellyfin music artist scan", scanned_artists)
|
||||
|
||||
def scanned_episodes() -> list[dict[str, Any]] | None:
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
"Recursive": "true",
|
||||
"IncludeItemTypes": "Episode",
|
||||
"Fields": "MediaSources,MediaStreams,SeriesId,ParentId,IndexNumber",
|
||||
"Limit": "100",
|
||||
}
|
||||
)
|
||||
result = api.json("GET", f"/Users/{user_id}/Items?{query}", token=token)
|
||||
episodes = result.get("Items", [])
|
||||
by_title = {item.get("Name"): item for item in episodes}
|
||||
if not set(EPISODE_TITLES).issubset(by_title):
|
||||
return None
|
||||
if any(not by_title[title].get("MediaSources") for title in EPISODE_TITLES):
|
||||
return None
|
||||
return episodes
|
||||
|
||||
episode_items = _wait_until(deadline, "Jellyfin episode scan", scanned_episodes)
|
||||
guest_authentication = _wait_until(
|
||||
deadline,
|
||||
"Jellyfin guest authentication",
|
||||
lambda: authenticate(GUEST_USERNAME, GUEST_PASSWORD),
|
||||
)
|
||||
guest_token = guest_authentication["AccessToken"]
|
||||
|
||||
def scanned_guest_items() -> list[dict[str, Any]] | None:
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
"Recursive": "true",
|
||||
"IncludeItemTypes": "Movie",
|
||||
"Fields": "MediaSources,MediaStreams",
|
||||
"Limit": "100",
|
||||
}
|
||||
)
|
||||
result = api.json("GET", f"/Users/{guest_user['Id']}/Items?{query}", token=guest_token)
|
||||
guest_items = result.get("Items", [])
|
||||
by_title = {item.get("Name"): item for item in guest_items}
|
||||
if GUEST_TITLE not in by_title or not by_title[GUEST_TITLE].get("MediaSources"):
|
||||
return None
|
||||
if BASE_TITLE in by_title:
|
||||
raise RuntimeError("Guest Jellyfin user can see the main library")
|
||||
return guest_items
|
||||
|
||||
guest_items = _wait_until(deadline, "Jellyfin guest media scan", scanned_guest_items)
|
||||
return {
|
||||
"server": SERVER_NAME,
|
||||
"version": "10.11.11",
|
||||
"userId": user_id,
|
||||
"guestUserId": guest_user["Id"],
|
||||
"titles": sorted(item["Name"] for item in items if item.get("Name") in expected_titles),
|
||||
"musicTitles": sorted(item["Name"] for item in music_items if item.get("Name") in required_music),
|
||||
"artistTitles": sorted(item["Name"] for item in artist_items if item.get("Name") == MUSIC_ARTIST),
|
||||
"episodeTitles": sorted(item["Name"] for item in episode_items if item.get("Name") in EPISODE_TITLES),
|
||||
"guestTitles": sorted(item["Name"] for item in guest_items if item.get("Name") == GUEST_TITLE),
|
||||
}
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
prepare = subparsers.add_parser("prepare", help="Create the deterministic media staging tree")
|
||||
prepare.add_argument("--output-dir", type=Path, required=True)
|
||||
prepare.add_argument("--codec-source-dir", type=Path)
|
||||
prepare.add_argument("--include-codecs", action="store_true")
|
||||
|
||||
download = subparsers.add_parser("download-codecs", help="Download and verify the hosted codec fixtures")
|
||||
download.add_argument("--output-dir", type=Path, required=True)
|
||||
download.add_argument(
|
||||
"--base-url",
|
||||
default=os.environ.get("PLEZY_DEMO_MEDIA_BASE_URL", DEFAULT_CODEC_BASE_URL),
|
||||
)
|
||||
|
||||
bootstrap = subparsers.add_parser("bootstrap", help="Configure and verify a fresh Jellyfin server")
|
||||
bootstrap.add_argument("--url", required=True)
|
||||
bootstrap.add_argument("--expected-title", action="append", default=[])
|
||||
bootstrap.add_argument("--expected-music-title", action="append", default=[])
|
||||
bootstrap.add_argument("--timeout", type=int, default=600)
|
||||
bootstrap.add_argument("--include-codecs", action="store_true")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _build_parser().parse_args()
|
||||
try:
|
||||
if args.command == "download-codecs":
|
||||
filenames = download_codec_media(args.output_dir, args.base_url)
|
||||
print(json.dumps({"codecDir": str(args.output_dir.resolve()), "files": filenames}, sort_keys=True))
|
||||
elif args.command == "prepare":
|
||||
titles = prepare_media(args.output_dir, args.codec_source_dir, args.include_codecs)
|
||||
print(json.dumps({"mediaDir": str(args.output_dir.resolve()), "titles": titles}, sort_keys=True))
|
||||
else:
|
||||
expected = {BASE_TITLE, *args.expected_title}
|
||||
expected.update(
|
||||
title if copy == 1 else f"{title} {copy}"
|
||||
for title in ALPHABET_TITLES
|
||||
for copy in range(1, 5)
|
||||
)
|
||||
if args.include_codecs:
|
||||
expected.update(spec.title for spec in MEDIA_FIXTURE_SPECS)
|
||||
expected_music = set(args.expected_music_title) or {MUSIC_TRACK}
|
||||
result = bootstrap_server(args.url, expected, args.timeout, expected_music)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
except (OSError, RuntimeError, TimeoutError, ValueError) as error:
|
||||
print(f"Real Jellyfin E2E setup failed: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Remux local codec fixtures into long-running copies for interactive E2E flows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from maestro_fixtures import MEDIA_FIXTURE_SPECS
|
||||
|
||||
|
||||
def _duration(path: Path) -> float:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"json",
|
||||
str(path),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
payload = json.loads(result.stdout)
|
||||
try:
|
||||
duration = float(payload["format"]["duration"])
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
raise ValueError(f"ffprobe returned no duration for {path}") from error
|
||||
if duration <= 0:
|
||||
raise ValueError(f"media duration must be positive: {path}")
|
||||
return duration
|
||||
|
||||
|
||||
def prepare_media(
|
||||
source_directory: Path,
|
||||
output_directory: Path,
|
||||
*,
|
||||
duration: float,
|
||||
extend_filenames: frozenset[str] | None = None,
|
||||
) -> None:
|
||||
if duration <= 0:
|
||||
raise ValueError("target duration must be positive")
|
||||
if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None:
|
||||
raise RuntimeError("ffmpeg and ffprobe are required for the local codec suite")
|
||||
|
||||
source_directory = source_directory.expanduser().resolve()
|
||||
output_directory = output_directory.expanduser().resolve()
|
||||
output_directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for spec in MEDIA_FIXTURE_SPECS:
|
||||
source = source_directory / spec.filename
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError(f"missing codec fixture: {source}")
|
||||
output = output_directory / spec.filename
|
||||
if extend_filenames is not None and spec.filename not in extend_filenames:
|
||||
shutil.copy2(source, output)
|
||||
continue
|
||||
if output.is_file() and output.stat().st_mtime_ns >= source.stat().st_mtime_ns:
|
||||
try:
|
||||
if _duration(output) >= duration:
|
||||
print(f"Reusing {output.name}")
|
||||
continue
|
||||
except (subprocess.CalledProcessError, ValueError):
|
||||
pass
|
||||
|
||||
source_duration = _duration(source)
|
||||
remux_duration = duration + 5
|
||||
repeat_count = max(0, math.ceil(remux_duration / source_duration) - 1)
|
||||
temporary = output.with_name(f"{output.stem}.tmp{output.suffix}")
|
||||
temporary.unlink(missing_ok=True)
|
||||
print(f"Preparing {output.name} ({source_duration:.1f}s -> {duration:.1f}s)")
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-v",
|
||||
"error",
|
||||
"-stream_loop",
|
||||
str(repeat_count),
|
||||
"-i",
|
||||
str(source),
|
||||
"-map",
|
||||
"0",
|
||||
"-c",
|
||||
"copy",
|
||||
"-t",
|
||||
str(remux_duration),
|
||||
str(temporary),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
if _duration(temporary) < duration:
|
||||
raise ValueError(f"prepared fixture is shorter than {duration}s: {temporary}")
|
||||
temporary.replace(output)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("source", type=Path, help="Directory containing the six untracked codec fixtures")
|
||||
parser.add_argument("output", type=Path, help="Directory for derived long-running fixtures")
|
||||
parser.add_argument("--duration", type=float, default=300, help="Minimum output duration in seconds")
|
||||
parser.add_argument(
|
||||
"--extend",
|
||||
action="append",
|
||||
choices=[spec.filename for spec in MEDIA_FIXTURE_SPECS],
|
||||
dest="extend_filenames",
|
||||
help="Only extend this fixture; may be repeated. By default every fixture is extended.",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(sys.argv[1:] if argv is None else argv)
|
||||
try:
|
||||
extend_filenames = frozenset(args.extend_filenames) if args.extend_filenames is not None else None
|
||||
prepare_media(
|
||||
args.source,
|
||||
args.output,
|
||||
duration=args.duration,
|
||||
extend_filenames=extend_filenames,
|
||||
)
|
||||
except (FileNotFoundError, RuntimeError, ValueError, subprocess.CalledProcessError) as error:
|
||||
print(f"Codec fixture preparation failed: {error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+739
@@ -0,0 +1,739 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the Jellyfin fixture and run Plezy's Android Maestro suites."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import signal
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Mapping, Optional, Sequence, TextIO
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||
APP_ID = "com.edde746.plezy"
|
||||
FAULTS = ("music-failure", "recovery")
|
||||
|
||||
|
||||
class RunnerError(RuntimeError):
|
||||
"""A user-actionable runner failure."""
|
||||
|
||||
class RunnerSignal(Exception):
|
||||
def __init__(self, signum: int) -> None:
|
||||
super().__init__(f"Interrupted by signal {signum}")
|
||||
self.exit_status = 128 + signum
|
||||
|
||||
|
||||
def _raise_signal(signum: int, _frame: object) -> None:
|
||||
raise RunnerSignal(signum)
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SuitePreset:
|
||||
flow_target: str
|
||||
maestro_config: Optional[str]
|
||||
jellyfin_log: str
|
||||
diagnostics_dir: str
|
||||
use_adb_reverse: bool = False
|
||||
uninstall_before_install: bool = False
|
||||
|
||||
|
||||
SUITES = {
|
||||
"basic": SuitePreset(
|
||||
flow_target=".maestro",
|
||||
maestro_config=None,
|
||||
jellyfin_log="build/maestro/jellyfin.log",
|
||||
diagnostics_dir="build/maestro/diagnostics",
|
||||
),
|
||||
"catalog": SuitePreset(
|
||||
flow_target=".maestro/real_flows",
|
||||
maestro_config=None,
|
||||
jellyfin_log="build/maestro-real-jellyfin/jellyfin.log",
|
||||
diagnostics_dir="build/maestro-real-jellyfin/diagnostics",
|
||||
uninstall_before_install=True,
|
||||
),
|
||||
"media": SuitePreset(
|
||||
flow_target=".maestro/media_flows",
|
||||
maestro_config=".maestro/media-config.yaml",
|
||||
jellyfin_log="build/maestro-media/jellyfin.log",
|
||||
diagnostics_dir="build/maestro/diagnostics",
|
||||
use_adb_reverse=True,
|
||||
uninstall_before_install=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunnerConfig:
|
||||
command: str
|
||||
jellyfin_host: str
|
||||
jellyfin_port: int
|
||||
proxy_port: int
|
||||
jellyfin_image: str
|
||||
skip_jellyfin: bool
|
||||
skip_jellyfin_build: bool
|
||||
skip_build: bool
|
||||
jellyfin_fault: Optional[str]
|
||||
use_adb_reverse: bool
|
||||
device_id: Optional[str]
|
||||
apk_path: Path
|
||||
flow_target: Path
|
||||
maestro_config: Optional[Path]
|
||||
uninstall_before_install: bool
|
||||
diagnostics_dir: Path
|
||||
jellyfin_log: Path
|
||||
proxy_log: Path
|
||||
proxy_journal: Path
|
||||
host_jellyfin_url: str
|
||||
jellyfin_url: Optional[str]
|
||||
jellyfin_build_attempts: int
|
||||
|
||||
|
||||
def _positive_int(value: str) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except ValueError as error:
|
||||
raise argparse.ArgumentTypeError("must be a positive integer") from error
|
||||
if parsed < 1:
|
||||
raise argparse.ArgumentTypeError("must be a positive integer")
|
||||
return parsed
|
||||
|
||||
|
||||
def _add_bool_argument(parser: argparse.ArgumentParser, name: str, *, destination: str) -> None:
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
group.add_argument(f"--{name}", dest=destination, action="store_true")
|
||||
group.add_argument(f"--no-{name}", dest=destination, action="store_false")
|
||||
parser.set_defaults(**{destination: None})
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=(*SUITES, "build-image"),
|
||||
nargs="?",
|
||||
default="basic",
|
||||
help="suite to run, or build-image to only build the Jellyfin fixture",
|
||||
)
|
||||
parser.add_argument("--device", dest="device_id")
|
||||
parser.add_argument("--apk", dest="apk_path")
|
||||
parser.add_argument("--flow", dest="flow_target")
|
||||
parser.add_argument("--config", dest="maestro_config")
|
||||
parser.add_argument("--fault", choices=FAULTS, dest="jellyfin_fault")
|
||||
parser.add_argument("--jellyfin-host")
|
||||
parser.add_argument("--jellyfin-port", type=int)
|
||||
parser.add_argument("--proxy-port", type=int)
|
||||
parser.add_argument("--jellyfin-image")
|
||||
parser.add_argument("--diagnostics-dir")
|
||||
parser.add_argument("--jellyfin-log")
|
||||
parser.add_argument("--proxy-log")
|
||||
parser.add_argument("--proxy-journal")
|
||||
parser.add_argument("--host-jellyfin-url")
|
||||
parser.add_argument("--jellyfin-url")
|
||||
parser.add_argument("--jellyfin-build-attempts", type=_positive_int)
|
||||
_add_bool_argument(parser, "skip-jellyfin", destination="skip_jellyfin")
|
||||
_add_bool_argument(parser, "skip-jellyfin-build", destination="skip_jellyfin_build")
|
||||
_add_bool_argument(parser, "skip-build", destination="skip_build")
|
||||
_add_bool_argument(parser, "adb-reverse", destination="use_adb_reverse")
|
||||
_add_bool_argument(parser, "uninstall-before-install", destination="uninstall_before_install")
|
||||
return parser
|
||||
|
||||
|
||||
def _env_bool(environment: Mapping[str, str], name: str, default: bool) -> bool:
|
||||
value = environment.get(name)
|
||||
if value is None:
|
||||
return default
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
raise RunnerError(f"{name} must be one of 1, 0, true, false, yes, no, on, or off")
|
||||
|
||||
|
||||
def _option(cli_value: object, environment: Mapping[str, str], name: str, default: object) -> object:
|
||||
if cli_value is not None:
|
||||
return cli_value
|
||||
return environment.get(name, default)
|
||||
def _int_option(
|
||||
cli_value: Optional[int],
|
||||
environment: Mapping[str, str],
|
||||
name: str,
|
||||
default: int,
|
||||
) -> int:
|
||||
value = _option(cli_value, environment, name, default)
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise RunnerError(f"{name} must be a positive integer") from error
|
||||
if parsed < 1:
|
||||
raise RunnerError(f"{name} must be a positive integer")
|
||||
return parsed
|
||||
|
||||
|
||||
|
||||
|
||||
def _bool_option(
|
||||
cli_value: Optional[bool],
|
||||
environment: Mapping[str, str],
|
||||
name: str,
|
||||
default: bool,
|
||||
) -> bool:
|
||||
if cli_value is not None:
|
||||
return cli_value
|
||||
return _env_bool(environment, name, default)
|
||||
|
||||
|
||||
def _root_path(value: object) -> Path:
|
||||
path = Path(str(value))
|
||||
return path if path.is_absolute() else ROOT_DIR / path
|
||||
|
||||
|
||||
def parse_config(argv: Optional[Sequence[str]] = None, environment: Optional[Mapping[str, str]] = None) -> RunnerConfig:
|
||||
args = _parser().parse_args(argv)
|
||||
env = os.environ if environment is None else environment
|
||||
preset = SUITES.get(args.command, SUITES["basic"])
|
||||
|
||||
jellyfin_host = str(_option(args.jellyfin_host, env, "MAESTRO_JELLYFIN_HOST", "127.0.0.1"))
|
||||
jellyfin_port = _int_option(args.jellyfin_port, env, "MAESTRO_JELLYFIN_PORT", 8096)
|
||||
proxy_port = _int_option(args.proxy_port, env, "MAESTRO_JELLYFIN_PROXY_PORT", jellyfin_port + 1)
|
||||
config_value = _option(args.maestro_config, env, "MAESTRO_CONFIG", preset.maestro_config or "")
|
||||
fault_value = _option(args.jellyfin_fault, env, "MAESTRO_JELLYFIN_FAULT", "")
|
||||
fault = str(fault_value) or None
|
||||
if fault is not None and fault not in FAULTS:
|
||||
raise RunnerError(f"Unsupported MAESTRO_JELLYFIN_FAULT: {fault}")
|
||||
|
||||
build_attempts = _int_option(
|
||||
args.jellyfin_build_attempts,
|
||||
env,
|
||||
"MAESTRO_JELLYFIN_BUILD_ATTEMPTS",
|
||||
2,
|
||||
)
|
||||
|
||||
return RunnerConfig(
|
||||
command=args.command,
|
||||
jellyfin_host=jellyfin_host,
|
||||
jellyfin_port=jellyfin_port,
|
||||
proxy_port=proxy_port,
|
||||
jellyfin_image=str(
|
||||
_option(args.jellyfin_image, env, "MAESTRO_JELLYFIN_IMAGE", "plezy-jellyfin-demo:local")
|
||||
),
|
||||
skip_jellyfin=_bool_option(args.skip_jellyfin, env, "MAESTRO_SKIP_JELLYFIN", False),
|
||||
skip_jellyfin_build=_bool_option(
|
||||
args.skip_jellyfin_build,
|
||||
env,
|
||||
"MAESTRO_SKIP_JELLYFIN_BUILD",
|
||||
False,
|
||||
),
|
||||
skip_build=_bool_option(args.skip_build, env, "MAESTRO_SKIP_BUILD", False),
|
||||
jellyfin_fault=fault,
|
||||
use_adb_reverse=_bool_option(
|
||||
args.use_adb_reverse,
|
||||
env,
|
||||
"MAESTRO_USE_ADB_REVERSE",
|
||||
preset.use_adb_reverse,
|
||||
),
|
||||
device_id=str(_option(args.device_id, env, "MAESTRO_DEVICE_ID", "")) or None,
|
||||
apk_path=_root_path(
|
||||
_option(
|
||||
args.apk_path,
|
||||
env,
|
||||
"MAESTRO_APK_PATH",
|
||||
"build/app/outputs/flutter-apk/app-debug.apk",
|
||||
)
|
||||
),
|
||||
flow_target=_root_path(
|
||||
_option(args.flow_target, env, "MAESTRO_FLOW_TARGET", preset.flow_target)
|
||||
),
|
||||
maestro_config=_root_path(config_value) if config_value else None,
|
||||
uninstall_before_install=_bool_option(
|
||||
args.uninstall_before_install,
|
||||
env,
|
||||
"MAESTRO_UNINSTALL_BEFORE_INSTALL",
|
||||
preset.uninstall_before_install,
|
||||
),
|
||||
diagnostics_dir=_root_path(
|
||||
_option(args.diagnostics_dir, env, "MAESTRO_DIAGNOSTICS_DIR", preset.diagnostics_dir)
|
||||
),
|
||||
jellyfin_log=_root_path(
|
||||
_option(args.jellyfin_log, env, "MAESTRO_JELLYFIN_LOG", preset.jellyfin_log)
|
||||
),
|
||||
proxy_log=_root_path(
|
||||
_option(
|
||||
args.proxy_log,
|
||||
env,
|
||||
"MAESTRO_JELLYFIN_PROXY_LOG",
|
||||
"build/maestro/jellyfin-proxy.log",
|
||||
)
|
||||
),
|
||||
proxy_journal=_root_path(
|
||||
_option(
|
||||
args.proxy_journal,
|
||||
env,
|
||||
"MAESTRO_JELLYFIN_PROXY_JOURNAL",
|
||||
"build/maestro/jellyfin-proxy-journal.jsonl",
|
||||
)
|
||||
),
|
||||
host_jellyfin_url=str(
|
||||
_option(
|
||||
args.host_jellyfin_url,
|
||||
env,
|
||||
"MAESTRO_JELLYFIN_HOST_URL",
|
||||
f"http://{jellyfin_host}:{jellyfin_port}",
|
||||
)
|
||||
),
|
||||
jellyfin_url=str(_option(args.jellyfin_url, env, "MAESTRO_JELLYFIN_URL", "")) or None,
|
||||
jellyfin_build_attempts=build_attempts,
|
||||
)
|
||||
|
||||
|
||||
def _format_command(command: Sequence[object]) -> str:
|
||||
values = [str(value) for value in command]
|
||||
if os.name == "nt":
|
||||
return subprocess.list2cmdline(values)
|
||||
return shlex.join(values)
|
||||
|
||||
|
||||
def _run_checked(command: Sequence[object], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
values = [str(value) for value in command]
|
||||
print(f"+ {_format_command(values)}", flush=True)
|
||||
return subprocess.run(values, cwd=ROOT_DIR, check=True, text=True, **kwargs)
|
||||
|
||||
|
||||
def _require_commands(names: Sequence[str]) -> None:
|
||||
missing = [name for name in names if shutil.which(name) is None]
|
||||
if missing:
|
||||
raise RunnerError(f"Required command not found: {', '.join(missing)}")
|
||||
|
||||
|
||||
def build_jellyfin_image(config: RunnerConfig) -> None:
|
||||
_require_commands(("docker",))
|
||||
command = (
|
||||
"docker",
|
||||
"build",
|
||||
"--file",
|
||||
ROOT_DIR / ".maestro/jellyfin-demo/Dockerfile",
|
||||
"--tag",
|
||||
config.jellyfin_image,
|
||||
ROOT_DIR,
|
||||
)
|
||||
for attempt in range(1, config.jellyfin_build_attempts + 1):
|
||||
try:
|
||||
_run_checked(command)
|
||||
return
|
||||
except subprocess.CalledProcessError:
|
||||
if attempt == config.jellyfin_build_attempts:
|
||||
raise RunnerError(
|
||||
f"Jellyfin image build failed after {config.jellyfin_build_attempts} attempts"
|
||||
)
|
||||
print(f"Jellyfin image build attempt {attempt} failed; retrying", file=sys.stderr)
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
class MaestroRunner:
|
||||
def __init__(self, config: RunnerConfig) -> None:
|
||||
self.config = config
|
||||
self.device_id = config.device_id
|
||||
self.container_name: Optional[str] = None
|
||||
self.proxy_process: Optional[subprocess.Popen[str]] = None
|
||||
self.proxy_output: Optional[TextIO] = None
|
||||
self.reverse_configured = False
|
||||
self.device_service_port = config.jellyfin_port
|
||||
self.host_jellyfin_url = config.host_jellyfin_url
|
||||
self.device_settings: dict[tuple[str, str], str] = {}
|
||||
|
||||
@property
|
||||
def adb_prefix(self) -> list[str]:
|
||||
prefix = ["adb"]
|
||||
if self.device_id:
|
||||
prefix.extend(("-s", self.device_id))
|
||||
return prefix
|
||||
|
||||
def run(self) -> None:
|
||||
required = ["adb", "maestro"]
|
||||
if not self.config.skip_build:
|
||||
required.append("flutter")
|
||||
if not self.config.skip_jellyfin:
|
||||
required.append("docker")
|
||||
_require_commands(required)
|
||||
self._select_device()
|
||||
self._prepare_output_directories()
|
||||
|
||||
if not self.config.skip_jellyfin:
|
||||
if not self.config.skip_jellyfin_build:
|
||||
build_jellyfin_image(self.config)
|
||||
self._start_jellyfin()
|
||||
self._wait_for_health(self.host_jellyfin_url, attempts=120, interval=0.25, service="Jellyfin")
|
||||
|
||||
if self.config.jellyfin_fault:
|
||||
self._start_proxy()
|
||||
|
||||
if not self.config.skip_build:
|
||||
_run_checked(("flutter", "pub", "get"))
|
||||
_run_checked(("flutter", "build", "apk", "--debug"))
|
||||
|
||||
self._prepare_device()
|
||||
_run_checked((*self.adb_prefix, "install", "-r", self.config.apk_path))
|
||||
_run_checked(self.maestro_command())
|
||||
|
||||
def maestro_command(self) -> list[str]:
|
||||
default_url = f"http://10.0.2.2:{self.device_service_port}"
|
||||
if self.config.use_adb_reverse:
|
||||
default_url = f"http://127.0.0.1:{self.device_service_port}"
|
||||
jellyfin_url = self.config.jellyfin_url or default_url
|
||||
command = ["maestro", "test", "-e", f"JELLYFIN_URL={jellyfin_url}"]
|
||||
if self.device_id:
|
||||
command.extend(("--device", self.device_id))
|
||||
if self.config.maestro_config:
|
||||
command.extend(("--config", str(self.config.maestro_config)))
|
||||
command.append(str(self.config.flow_target))
|
||||
return command
|
||||
|
||||
def _prepare_output_directories(self) -> None:
|
||||
for path in (
|
||||
self.config.diagnostics_dir,
|
||||
self.config.jellyfin_log.parent,
|
||||
self.config.proxy_log.parent,
|
||||
self.config.proxy_journal.parent,
|
||||
):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _select_device(self) -> None:
|
||||
if not self.config.use_adb_reverse or self.device_id:
|
||||
return
|
||||
result = subprocess.run(
|
||||
("adb", "devices"),
|
||||
cwd=ROOT_DIR,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
devices = []
|
||||
for line in result.stdout.splitlines()[1:]:
|
||||
fields = line.split()
|
||||
if len(fields) >= 2 and fields[1] == "device":
|
||||
devices.append(fields[0])
|
||||
if len(devices) != 1:
|
||||
raise RunnerError("Set --device to the Android device serial when using --adb-reverse")
|
||||
self.device_id = devices[0]
|
||||
|
||||
def _start_jellyfin(self) -> None:
|
||||
name = f"plezy-maestro-jellyfin-{self.config.jellyfin_port}-{os.getpid()}"
|
||||
result = _run_checked(
|
||||
(
|
||||
"docker",
|
||||
"run",
|
||||
"--detach",
|
||||
"--rm",
|
||||
"--name",
|
||||
name,
|
||||
"--publish",
|
||||
f"{self.config.jellyfin_host}:{self.config.jellyfin_port}:8096",
|
||||
self.config.jellyfin_image,
|
||||
),
|
||||
capture_output=True,
|
||||
)
|
||||
self.container_name = result.stdout.strip() or name
|
||||
|
||||
def _wait_for_health(self, base_url: str, *, attempts: int, interval: float, service: str) -> None:
|
||||
health_url = f"{base_url.rstrip('/')}/health"
|
||||
last_error: Optional[Exception] = None
|
||||
for _ in range(attempts):
|
||||
try:
|
||||
with urllib.request.urlopen(health_url, timeout=1) as response:
|
||||
response.read()
|
||||
return
|
||||
except (OSError, urllib.error.URLError) as error:
|
||||
last_error = error
|
||||
time.sleep(interval)
|
||||
raise RunnerError(f"{service} did not become ready at {base_url}: {last_error}")
|
||||
|
||||
def _start_proxy(self) -> None:
|
||||
self.proxy_output = self.config.proxy_log.open("w", encoding="utf-8")
|
||||
self.proxy_process = subprocess.Popen(
|
||||
(
|
||||
sys.executable,
|
||||
str(ROOT_DIR / "scripts/maestro_jellyfin_proxy.py"),
|
||||
"--host",
|
||||
self.config.jellyfin_host,
|
||||
"--port",
|
||||
str(self.config.proxy_port),
|
||||
"--upstream",
|
||||
self.host_jellyfin_url,
|
||||
"--fault",
|
||||
self.config.jellyfin_fault or "",
|
||||
"--journal",
|
||||
str(self.config.proxy_journal),
|
||||
),
|
||||
cwd=ROOT_DIR,
|
||||
stdout=self.proxy_output,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
proxy_url = f"http://{self.config.jellyfin_host}:{self.config.proxy_port}"
|
||||
for _ in range(50):
|
||||
if self.proxy_process.poll() is not None:
|
||||
raise RunnerError(f"Jellyfin fault proxy exited early; see {self.config.proxy_log}")
|
||||
try:
|
||||
self._wait_for_health(proxy_url, attempts=1, interval=0, service="Jellyfin fault proxy")
|
||||
self.host_jellyfin_url = proxy_url
|
||||
self.device_service_port = self.config.proxy_port
|
||||
return
|
||||
except RunnerError:
|
||||
time.sleep(0.1)
|
||||
raise RunnerError(f"Jellyfin fault proxy did not become ready; see {self.config.proxy_log}")
|
||||
|
||||
def _adb_run(
|
||||
self,
|
||||
*arguments: object,
|
||||
check: bool = True,
|
||||
quiet: bool = False,
|
||||
timeout: int = 30,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
kwargs: dict[str, object] = {}
|
||||
if quiet:
|
||||
kwargs.update(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return subprocess.run(
|
||||
[*self.adb_prefix, *(str(argument) for argument in arguments)],
|
||||
cwd=ROOT_DIR,
|
||||
check=check,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _adb_capture(self, *arguments: object) -> Optional[str]:
|
||||
command = [*self.adb_prefix, *(str(argument) for argument in arguments)]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=ROOT_DIR,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise RunnerError(f"ADB command timed out: {_format_command(command)}") from error
|
||||
return result.stdout.strip() if result.returncode == 0 else None
|
||||
|
||||
def _prepare_device(self) -> None:
|
||||
_run_checked((*self.adb_prefix, "wait-for-device"), timeout=60)
|
||||
for namespace, key in (
|
||||
("global", "stay_on_while_plugged_in"),
|
||||
("secure", "immersive_mode_confirmations"),
|
||||
("global", "hide_error_dialogs"),
|
||||
):
|
||||
value = self._adb_capture("shell", "settings", "get", namespace, key)
|
||||
if value is not None:
|
||||
self.device_settings[(namespace, key)] = value
|
||||
|
||||
self._adb_run(
|
||||
"shell",
|
||||
"settings",
|
||||
"put",
|
||||
"secure",
|
||||
"immersive_mode_confirmations",
|
||||
"confirmed",
|
||||
check=False,
|
||||
quiet=True,
|
||||
)
|
||||
self._adb_run(
|
||||
"shell",
|
||||
"settings",
|
||||
"put",
|
||||
"global",
|
||||
"hide_error_dialogs",
|
||||
"1",
|
||||
check=False,
|
||||
quiet=True,
|
||||
)
|
||||
self._adb_run("shell", "input", "keyevent", "KEYCODE_BACK", check=False, quiet=True)
|
||||
_run_checked((*self.adb_prefix, "shell", "svc", "power", "stayon", "true"))
|
||||
_run_checked((*self.adb_prefix, "shell", "input", "keyevent", "KEYCODE_WAKEUP"))
|
||||
_run_checked((*self.adb_prefix, "shell", "wm", "dismiss-keyguard"))
|
||||
|
||||
if self.config.use_adb_reverse:
|
||||
_run_checked(
|
||||
(
|
||||
*self.adb_prefix,
|
||||
"reverse",
|
||||
f"tcp:{self.device_service_port}",
|
||||
f"tcp:{self.device_service_port}",
|
||||
)
|
||||
)
|
||||
self.reverse_configured = True
|
||||
if self.config.uninstall_before_install:
|
||||
self._adb_run("uninstall", APP_ID, check=False, quiet=True)
|
||||
|
||||
def collect_failure_diagnostics(self, exit_status: int) -> None:
|
||||
try:
|
||||
self.config.diagnostics_dir.mkdir(parents=True, exist_ok=True)
|
||||
state_path = self.config.diagnostics_dir / "run-state.txt"
|
||||
with state_path.open("w", encoding="utf-8") as output:
|
||||
output.write(f"exit_status={exit_status}\n")
|
||||
output.write(f"jellyfin_host_url={self.host_jellyfin_url}\n")
|
||||
output.write(f"jellyfin_container={self.container_name or ''}\n")
|
||||
output.write(f"jellyfin_fault={self.config.jellyfin_fault or ''}\n")
|
||||
output.write(f"proxy_pid={self.proxy_process.pid if self.proxy_process else ''}\n")
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
f"{self.host_jellyfin_url.rstrip('/')}/health",
|
||||
timeout=2,
|
||||
) as response:
|
||||
output.write(response.read().decode(errors="replace"))
|
||||
output.write("\n")
|
||||
except Exception as error: # Diagnostics must not hide the original failure.
|
||||
output.write(f"health_error={error}\n")
|
||||
|
||||
if self.container_name:
|
||||
self._write_command_output(
|
||||
("docker", "logs", self.container_name),
|
||||
self.config.jellyfin_log,
|
||||
)
|
||||
self._write_command_output(("adb", "devices", "-l"), self.config.diagnostics_dir / "adb-devices.txt")
|
||||
for filename, arguments in (
|
||||
("device-properties.txt", ("shell", "getprop")),
|
||||
("device-processes.txt", ("shell", "ps", "-A")),
|
||||
("device-activities.txt", ("shell", "dumpsys", "activity", "activities")),
|
||||
("device-windows.txt", ("shell", "dumpsys", "window", "windows")),
|
||||
("device-logcat.txt", ("logcat", "-d", "-v", "threadtime")),
|
||||
):
|
||||
self._write_command_output(
|
||||
(*self.adb_prefix, *arguments),
|
||||
self.config.diagnostics_dir / filename,
|
||||
)
|
||||
if os.name == "nt" and shutil.which("tasklist"):
|
||||
self._write_command_output(("tasklist",), self.config.diagnostics_dir / "host-processes.txt")
|
||||
elif shutil.which("ps"):
|
||||
self._write_command_output(("ps", "-ef"), self.config.diagnostics_dir / "host-processes.txt")
|
||||
if shutil.which("lsof"):
|
||||
self._write_command_output(
|
||||
("lsof", "-nP", f"-iTCP:{self.device_service_port}"),
|
||||
self.config.diagnostics_dir / "jellyfin-listeners.txt",
|
||||
)
|
||||
except Exception as error: # Diagnostics are best effort.
|
||||
print(f"Failed to collect Maestro diagnostics: {error}", file=sys.stderr)
|
||||
|
||||
def _write_command_output(self, command: Sequence[object], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as output:
|
||||
subprocess.run(
|
||||
[str(value) for value in command],
|
||||
cwd=ROOT_DIR,
|
||||
check=False,
|
||||
stdout=output,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
def _adb_best_effort(self, *arguments: object) -> None:
|
||||
try:
|
||||
self._adb_run(*arguments, check=False, quiet=True, timeout=5)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
def cleanup(self) -> None:
|
||||
for (namespace, key), value in self.device_settings.items():
|
||||
operation = "delete" if value == "null" else "put"
|
||||
arguments = ["shell", "settings", operation, namespace, key]
|
||||
if operation == "put":
|
||||
arguments.append(value)
|
||||
self._adb_best_effort(*arguments)
|
||||
|
||||
if self.reverse_configured:
|
||||
self._adb_best_effort(
|
||||
"reverse",
|
||||
"--remove",
|
||||
f"tcp:{self.device_service_port}",
|
||||
)
|
||||
try:
|
||||
if self.proxy_process:
|
||||
self.proxy_process.terminate()
|
||||
try:
|
||||
self.proxy_process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.proxy_process.kill()
|
||||
self.proxy_process.wait()
|
||||
finally:
|
||||
if self.proxy_output:
|
||||
self.proxy_output.close()
|
||||
|
||||
if self.container_name:
|
||||
try:
|
||||
self._write_command_output(
|
||||
("docker", "logs", self.container_name),
|
||||
self.config.jellyfin_log,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
try:
|
||||
subprocess.run(
|
||||
("docker", "stop", "--time", "15", self.container_name),
|
||||
cwd=ROOT_DIR,
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
|
||||
def run(config: RunnerConfig) -> int:
|
||||
if config.command == "build-image":
|
||||
build_jellyfin_image(config)
|
||||
return 0
|
||||
|
||||
runner = MaestroRunner(config)
|
||||
exit_status = 0
|
||||
try:
|
||||
runner.run()
|
||||
except KeyboardInterrupt:
|
||||
exit_status = 130
|
||||
print("Maestro run interrupted", file=sys.stderr)
|
||||
except RunnerSignal as error:
|
||||
exit_status = error.exit_status
|
||||
print(str(error), file=sys.stderr)
|
||||
except subprocess.CalledProcessError as error:
|
||||
exit_status = error.returncode or 1
|
||||
print(f"Command failed ({exit_status}): {_format_command(error.cmd)}", file=sys.stderr)
|
||||
except (OSError, RunnerError) as error:
|
||||
exit_status = 1
|
||||
print(error, file=sys.stderr)
|
||||
finally:
|
||||
if exit_status:
|
||||
runner.collect_failure_diagnostics(exit_status)
|
||||
runner.cleanup()
|
||||
return exit_status
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
signal.signal(signal.SIGINT, _raise_signal)
|
||||
signal.signal(signal.SIGTERM, _raise_signal)
|
||||
try:
|
||||
config = parse_config(argv)
|
||||
return run(config)
|
||||
except RunnerSignal as error:
|
||||
return error.exit_status
|
||||
except KeyboardInterrupt:
|
||||
return 130
|
||||
except (OSError, RunnerError, subprocess.CalledProcessError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+140
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from maestro_jellyfin_proxy import JellyfinProxyHandler, ProxyState # noqa: E402
|
||||
|
||||
|
||||
class _UpstreamHandler(BaseHTTPRequestHandler):
|
||||
requests: list[tuple[str, str, bytes, str | None, str | None]] = []
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self._respond()
|
||||
|
||||
def do_POST(self) -> None:
|
||||
self._respond()
|
||||
|
||||
def _respond(self) -> None:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
body = self.rfile.read(length) if length else b""
|
||||
self.requests.append(
|
||||
(
|
||||
self.command,
|
||||
self.path,
|
||||
body,
|
||||
self.headers.get("X-Emby-Token"),
|
||||
self.headers.get("Accept-Encoding"),
|
||||
)
|
||||
)
|
||||
payload = b"real jellyfin response"
|
||||
self.send_response(206 if self.headers.get("Range") else 200)
|
||||
self.send_header("Content-Type", "application/octet-stream")
|
||||
self.send_header("Accept-Ranges", "bytes")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class JellyfinProxyTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
_UpstreamHandler.requests = []
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.upstream = ThreadingHTTPServer(("127.0.0.1", 0), _UpstreamHandler)
|
||||
self.upstream_thread = threading.Thread(target=self.upstream.serve_forever, daemon=True)
|
||||
self.upstream_thread.start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.upstream.shutdown()
|
||||
self.upstream.server_close()
|
||||
self.upstream_thread.join(timeout=5)
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def _start_proxy(self, fault: str | None) -> tuple[ThreadingHTTPServer, threading.Thread, str, Path]:
|
||||
journal = Path(self.temp_dir.name) / "journal.jsonl"
|
||||
proxy = ThreadingHTTPServer(("127.0.0.1", 0), JellyfinProxyHandler)
|
||||
proxy.daemon_threads = True
|
||||
upstream_url = f"http://127.0.0.1:{self.upstream.server_port}"
|
||||
proxy.state = ProxyState(upstream_url, fault, journal) # type: ignore[attr-defined]
|
||||
thread = threading.Thread(target=proxy.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return proxy, thread, f"http://127.0.0.1:{proxy.server_port}", journal
|
||||
|
||||
def _stop_proxy(self, proxy: ThreadingHTTPServer, thread: threading.Thread) -> None:
|
||||
proxy.shutdown()
|
||||
proxy.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
def test_forwards_methods_bodies_tokens_and_range_responses(self) -> None:
|
||||
proxy, thread, base_url, _ = self._start_proxy(None)
|
||||
try:
|
||||
request = urllib.request.Request(
|
||||
base_url + "/Items?id=movie",
|
||||
data=b'{"played":true}',
|
||||
method="POST",
|
||||
headers={
|
||||
"X-Emby-Token": "token",
|
||||
"Range": "bytes=0-9",
|
||||
"Content-Type": "application/json",
|
||||
"Accept-Encoding": "gzip",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request) as response:
|
||||
self.assertEqual(response.status, 206)
|
||||
self.assertEqual(response.headers["Accept-Ranges"], "bytes")
|
||||
self.assertEqual(response.read(), b"real jellyfin response")
|
||||
self.assertEqual(
|
||||
_UpstreamHandler.requests,
|
||||
[("POST", "/Items?id=movie", b'{"played":true}', "token", "identity")],
|
||||
)
|
||||
finally:
|
||||
self._stop_proxy(proxy, thread)
|
||||
|
||||
def test_recovery_faults_only_the_first_video_stream_request(self) -> None:
|
||||
proxy, thread, base_url, journal = self._start_proxy("recovery")
|
||||
try:
|
||||
with self.assertRaises(urllib.error.HTTPError) as first:
|
||||
urllib.request.urlopen(base_url + "/Videos/movie/stream.mp4?Static=true")
|
||||
self.assertEqual(first.exception.code, 503)
|
||||
first.exception.close()
|
||||
with urllib.request.urlopen(base_url + "/Videos/movie/stream.mp4?Static=true") as second:
|
||||
self.assertEqual(second.status, 200)
|
||||
self.assertEqual(len(_UpstreamHandler.requests), 1)
|
||||
events = [json.loads(line) for line in journal.read_text(encoding="utf-8").splitlines()]
|
||||
self.assertEqual([event["kind"] for event in events], ["fault", "request"])
|
||||
self.assertEqual(
|
||||
[event["path"] for event in events],
|
||||
["/Videos/movie/stream.mp4", "/Videos/movie/stream.mp4"],
|
||||
)
|
||||
finally:
|
||||
self._stop_proxy(proxy, thread)
|
||||
|
||||
def test_music_fault_does_not_affect_other_requests(self) -> None:
|
||||
proxy, thread, base_url, _ = self._start_proxy("music-failure")
|
||||
try:
|
||||
with urllib.request.urlopen(base_url + "/Items") as response:
|
||||
self.assertEqual(response.status, 200)
|
||||
with self.assertRaises(urllib.error.HTTPError) as failure:
|
||||
urllib.request.urlopen(base_url + "/Artists/AlbumArtists?UserId=user")
|
||||
self.assertEqual(failure.exception.code, 503)
|
||||
failure.exception.close()
|
||||
with urllib.request.urlopen(base_url + "/Artists/AlbumArtists?UserId=user") as recovered:
|
||||
self.assertEqual(recovered.status, 200)
|
||||
finally:
|
||||
self._stop_proxy(proxy, thread)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+159
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
import unittest
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from maestro_fixtures import MEDIA_FIXTURE_SPECS, MediaFixtureSpec # noqa: E402
|
||||
import maestro_real_jellyfin as real_jellyfin # noqa: E402
|
||||
from maestro_real_jellyfin import ( # noqa: E402
|
||||
ALPHABET_TITLES,
|
||||
BASE_TITLE,
|
||||
EPISODE_TITLES,
|
||||
GUEST_TITLE,
|
||||
download_codec_media,
|
||||
prepare_media,
|
||||
)
|
||||
|
||||
|
||||
class PrepareMediaTests(unittest.TestCase):
|
||||
def test_base_media_is_deterministic_and_repeatable(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
output = Path(temp_dir) / "media"
|
||||
|
||||
titles = prepare_media(output, None, False)
|
||||
self.assertEqual(titles[0], BASE_TITLE)
|
||||
self.assertEqual(len(titles), 1 + len(ALPHABET_TITLES) * 4)
|
||||
movie = output / "movies" / "maestro-movie" / f"{BASE_TITLE}.mp4"
|
||||
nfo = movie.with_suffix(".nfo")
|
||||
first_payload = movie.read_bytes()
|
||||
self.assertGreater(len(first_payload), 0)
|
||||
self.assertEqual(ET.parse(nfo).findtext("title"), BASE_TITLE)
|
||||
self.assertEqual(
|
||||
ET.parse(output / "guest-movies" / "guest-galaxy" / f"{GUEST_TITLE}.nfo").findtext("title"),
|
||||
GUEST_TITLE,
|
||||
)
|
||||
episode_nfo = output / "shows" / "Maestro Show" / "Season 01" / (
|
||||
f"Maestro Show S01E01 - {EPISODE_TITLES[0]}.nfo"
|
||||
)
|
||||
self.assertEqual(ET.parse(episode_nfo).findtext("title"), EPISODE_TITLES[0])
|
||||
|
||||
(output / "obsolete").mkdir()
|
||||
self.assertEqual(prepare_media(output, None, False), titles)
|
||||
self.assertEqual(movie.read_bytes(), first_payload)
|
||||
self.assertFalse((output / "obsolete").exists())
|
||||
|
||||
def test_codec_media_uses_hard_links_and_exact_titles(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
source = root / "source"
|
||||
source.mkdir()
|
||||
for spec in MEDIA_FIXTURE_SPECS:
|
||||
(source / spec.filename).write_bytes(spec.id.encode("utf-8"))
|
||||
|
||||
output = root / "media"
|
||||
titles = prepare_media(output, source, True)
|
||||
|
||||
self.assertEqual(titles[0], BASE_TITLE)
|
||||
self.assertEqual(titles[-len(MEDIA_FIXTURE_SPECS) :], [spec.title for spec in MEDIA_FIXTURE_SPECS])
|
||||
for spec in MEDIA_FIXTURE_SPECS:
|
||||
staged = output / "movies" / spec.id / f"{spec.title}.mkv"
|
||||
self.assertTrue(staged.samefile(source / spec.filename))
|
||||
self.assertEqual(ET.parse(staged.with_suffix(".nfo")).findtext("title"), spec.title)
|
||||
|
||||
def test_codec_download_verifies_size_and_sha256_then_reuses_file(self) -> None:
|
||||
payload = b"deterministic codec payload"
|
||||
spec = MediaFixtureSpec(
|
||||
id="codec-test",
|
||||
title="Codec Test",
|
||||
filename="codec-test.mkv",
|
||||
overview="Test fixture.",
|
||||
video_codec="h264",
|
||||
width=320,
|
||||
height=180,
|
||||
size_bytes=len(payload),
|
||||
sha256=hashlib.sha256(payload).hexdigest(),
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
output = Path(temp_dir)
|
||||
response = io.BytesIO(payload)
|
||||
response.headers = {"Content-Length": str(len(payload))}
|
||||
with (
|
||||
patch.object(real_jellyfin, "MEDIA_FIXTURE_SPECS", (spec,)),
|
||||
patch.object(real_jellyfin.urllib.request, "urlopen", return_value=response) as urlopen,
|
||||
):
|
||||
self.assertEqual(download_codec_media(output, "https://media.example/"), [spec.filename])
|
||||
self.assertEqual((output / spec.filename).read_bytes(), payload)
|
||||
self.assertEqual(urlopen.call_count, 1)
|
||||
|
||||
with (
|
||||
patch.object(real_jellyfin, "MEDIA_FIXTURE_SPECS", (spec,)),
|
||||
patch.object(
|
||||
real_jellyfin.urllib.request,
|
||||
"urlopen",
|
||||
side_effect=AssertionError("valid cached fixture must not be downloaded"),
|
||||
),
|
||||
):
|
||||
self.assertEqual(download_codec_media(output, "https://media.example/"), [spec.filename])
|
||||
|
||||
def test_codec_download_rejects_corrupt_payload_without_leaving_partial_file(self) -> None:
|
||||
payload = b"corrupt"
|
||||
spec = MediaFixtureSpec(
|
||||
id="codec-test",
|
||||
title="Codec Test",
|
||||
filename="codec-test.mkv",
|
||||
overview="Test fixture.",
|
||||
video_codec="h264",
|
||||
width=320,
|
||||
height=180,
|
||||
size_bytes=len(payload),
|
||||
sha256=hashlib.sha256(b"expected").hexdigest(),
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
output = Path(temp_dir)
|
||||
response = io.BytesIO(payload)
|
||||
response.headers = {"Content-Length": str(len(payload))}
|
||||
with (
|
||||
patch.object(real_jellyfin, "MEDIA_FIXTURE_SPECS", (spec,)),
|
||||
patch.object(real_jellyfin.urllib.request, "urlopen", return_value=response),
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "failed SHA-256 verification"):
|
||||
download_codec_media(output, "https://media.example/")
|
||||
|
||||
self.assertFalse((output / spec.filename).exists())
|
||||
self.assertFalse((output / f"{spec.filename}.part").exists())
|
||||
|
||||
|
||||
def test_existing_empty_directory_can_become_managed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
output = Path(temp_dir) / "media"
|
||||
output.mkdir()
|
||||
|
||||
self.assertEqual(prepare_media(output, None, False)[0], BASE_TITLE)
|
||||
self.assertTrue((output / "movies" / "maestro-movie" / f"{BASE_TITLE}.mp4").is_file())
|
||||
|
||||
def test_existing_unmanaged_directory_is_never_cleared(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
output = Path(temp_dir) / "media"
|
||||
output.mkdir()
|
||||
sentinel = output / "keep.txt"
|
||||
sentinel.write_text("user data", encoding="utf-8")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "unmanaged media staging directory"):
|
||||
prepare_media(output, None, False)
|
||||
|
||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "user data")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+138
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import redirect_stderr
|
||||
from dataclasses import replace
|
||||
import io
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import run_maestro # noqa: E402
|
||||
|
||||
|
||||
class ParseConfigTests(unittest.TestCase):
|
||||
def test_basic_defaults(self) -> None:
|
||||
config = run_maestro.parse_config([], {})
|
||||
|
||||
self.assertEqual(config.command, "basic")
|
||||
self.assertEqual(config.flow_target, run_maestro.ROOT_DIR / ".maestro")
|
||||
self.assertIsNone(config.maestro_config)
|
||||
self.assertFalse(config.use_adb_reverse)
|
||||
self.assertFalse(config.uninstall_before_install)
|
||||
|
||||
def test_suite_presets_replace_shell_wrappers(self) -> None:
|
||||
catalog = run_maestro.parse_config(["catalog"], {})
|
||||
media = run_maestro.parse_config(["media"], {})
|
||||
|
||||
self.assertEqual(catalog.flow_target, run_maestro.ROOT_DIR / ".maestro/real_flows")
|
||||
self.assertEqual(
|
||||
catalog.diagnostics_dir,
|
||||
run_maestro.ROOT_DIR / "build/maestro-real-jellyfin/diagnostics",
|
||||
)
|
||||
self.assertTrue(catalog.uninstall_before_install)
|
||||
self.assertEqual(media.flow_target, run_maestro.ROOT_DIR / ".maestro/media_flows")
|
||||
self.assertEqual(media.maestro_config, run_maestro.ROOT_DIR / ".maestro/media-config.yaml")
|
||||
self.assertTrue(media.use_adb_reverse)
|
||||
self.assertTrue(media.uninstall_before_install)
|
||||
|
||||
def test_cli_options_override_compatible_environment_values(self) -> None:
|
||||
config = run_maestro.parse_config(
|
||||
[
|
||||
"media",
|
||||
"--no-adb-reverse",
|
||||
"--flow",
|
||||
"custom/flow.yaml",
|
||||
"--device",
|
||||
"cli-device",
|
||||
],
|
||||
{
|
||||
"MAESTRO_USE_ADB_REVERSE": "1",
|
||||
"MAESTRO_FLOW_TARGET": "environment/flow.yaml",
|
||||
"MAESTRO_DEVICE_ID": "environment-device",
|
||||
"MAESTRO_SKIP_BUILD": "true",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertFalse(config.use_adb_reverse)
|
||||
self.assertEqual(config.flow_target, run_maestro.ROOT_DIR / "custom/flow.yaml")
|
||||
self.assertEqual(config.device_id, "cli-device")
|
||||
self.assertTrue(config.skip_build)
|
||||
|
||||
def test_invalid_environment_values_fail_early(self) -> None:
|
||||
with self.assertRaisesRegex(run_maestro.RunnerError, "MAESTRO_SKIP_BUILD"):
|
||||
run_maestro.parse_config([], {"MAESTRO_SKIP_BUILD": "sometimes"})
|
||||
with self.assertRaisesRegex(run_maestro.RunnerError, "MAESTRO_JELLYFIN_PORT"):
|
||||
run_maestro.parse_config([], {"MAESTRO_JELLYFIN_PORT": "invalid"})
|
||||
with self.assertRaisesRegex(run_maestro.RunnerError, "MAESTRO_JELLYFIN_BUILD_ATTEMPTS"):
|
||||
run_maestro.parse_config([], {"MAESTRO_JELLYFIN_BUILD_ATTEMPTS": "0"})
|
||||
|
||||
|
||||
class CommandTests(unittest.TestCase):
|
||||
def test_media_command_contains_resolved_preset_and_device_url(self) -> None:
|
||||
config = run_maestro.parse_config(["media", "--device", "emulator-5554"], {})
|
||||
command = run_maestro.MaestroRunner(config).maestro_command()
|
||||
|
||||
self.assertEqual(
|
||||
command,
|
||||
[
|
||||
"maestro",
|
||||
"test",
|
||||
"-e",
|
||||
"JELLYFIN_URL=http://127.0.0.1:8096",
|
||||
"--device",
|
||||
"emulator-5554",
|
||||
"--config",
|
||||
str(run_maestro.ROOT_DIR / ".maestro/media-config.yaml"),
|
||||
str(run_maestro.ROOT_DIR / ".maestro/media_flows"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_explicit_device_url_wins_over_network_mode(self) -> None:
|
||||
config = run_maestro.parse_config(
|
||||
["basic", "--adb-reverse", "--jellyfin-url", "http://device.test:9000"],
|
||||
{},
|
||||
)
|
||||
|
||||
command = run_maestro.MaestroRunner(config).maestro_command()
|
||||
|
||||
self.assertIn("JELLYFIN_URL=http://device.test:9000", command)
|
||||
|
||||
|
||||
class LifecycleTests(unittest.TestCase):
|
||||
def test_runner_failure_collects_diagnostics_and_cleans_up(self) -> None:
|
||||
config = run_maestro.parse_config([], {})
|
||||
with patch.object(run_maestro, "MaestroRunner") as runner_type:
|
||||
runner = runner_type.return_value
|
||||
runner.run.side_effect = run_maestro.RunnerError("failed")
|
||||
with redirect_stderr(io.StringIO()):
|
||||
exit_status = run_maestro.run(config)
|
||||
|
||||
self.assertEqual(exit_status, 1)
|
||||
runner.collect_failure_diagnostics.assert_called_once_with(1)
|
||||
runner.cleanup.assert_called_once_with()
|
||||
|
||||
def test_image_build_retries_once(self) -> None:
|
||||
config = replace(run_maestro.parse_config(["build-image"], {}), jellyfin_build_attempts=2)
|
||||
failure = subprocess.CalledProcessError(1, ["docker", "build"])
|
||||
success = subprocess.CompletedProcess(["docker", "build"], 0)
|
||||
|
||||
with (
|
||||
patch.object(run_maestro, "_require_commands"),
|
||||
patch.object(run_maestro, "_run_checked", side_effect=[failure, success]) as run_command,
|
||||
patch.object(run_maestro.time, "sleep") as sleep,
|
||||
redirect_stderr(io.StringIO()),
|
||||
):
|
||||
run_maestro.build_jellyfin_image(config)
|
||||
|
||||
self.assertEqual(run_command.call_count, 2)
|
||||
sleep.assert_called_once_with(5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:ui' show Tristate;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
@@ -120,6 +122,11 @@ void main() {
|
||||
expect(find.text('AAC · Stereo'), findsOneWidget);
|
||||
expect(find.text('Tamil'), findsOneWidget);
|
||||
expect(find.text('Dolby Digital Plus 5.1 with Atmos · E-AC3 · 5.1'), findsOneWidget);
|
||||
|
||||
final englishTile = find.ancestor(of: find.text('English'), matching: find.byType(ListTile));
|
||||
final tamilTile = find.ancestor(of: find.text('Tamil'), matching: find.byType(ListTile));
|
||||
expect(tester.getSemantics(englishTile).getSemanticsData().flagsCollection.isSelected, Tristate.isTrue);
|
||||
expect(tester.getSemantics(tamilTile).getSemanticsData().flagsCollection.isSelected, Tristate.isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user