feat(test): add Maestro end-to-end coverage

This commit is contained in:
edde746
2026-07-15 06:40:35 +02:00
parent ddb7520ce8
commit 139cf83507
50 changed files with 3961 additions and 20 deletions
+7
View File
@@ -0,0 +1,7 @@
**
!.maestro/
!.maestro/jellyfin-demo/
!.maestro/jellyfin-demo/**
!scripts/
!scripts/maestro_fixtures.py
!scripts/maestro_real_jellyfin.py
+399
View File
@@ -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
+14
View File
@@ -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
+33
View File
@@ -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"
+13
View File
@@ -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.*"
+22
View File
@@ -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"
+24
View File
@@ -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.*"
+29
View File
@@ -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
+11
View File
@@ -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
+62
View File
@@ -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.*"
+23
View File
@@ -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"
+56
View File
@@ -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"]
+17
View File
@@ -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 "$@"
+46
View File
@@ -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
+12
View File
@@ -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
+26
View File
@@ -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
+26
View File
@@ -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
+26
View File
@@ -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
+26
View File
@@ -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
+27
View File
@@ -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
+26
View File
@@ -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
+38
View File
@@ -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
+54
View File
@@ -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.*"
+38
View File
@@ -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
+26
View File
@@ -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
+28
View File
@@ -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
View File
@@ -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
+27 -18
View File
@@ -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,24 +105,30 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
final gap = isTv ? 8.0 * tvScale : 12.0;
Widget playButton(FocusableActionBuildState state) {
return SizedBox(
height: actionSize,
child: FilledButton(
onPressed: onPlayPressed,
style: actionButtonStyle(
showFocus: state.showFocus,
padding: .symmetric(horizontal: isTv ? 17 * tvScale : 16, vertical: isTv ? 9 * tvScale : 0),
return Semantics(
label: playSemanticsLabel,
button: true,
onTap: onPlayPressed,
excludeSemantics: true,
child: SizedBox(
height: actionSize,
child: FilledButton(
onPressed: onPlayPressed,
style: actionButtonStyle(
showFocus: state.showFocus,
padding: .symmetric(horizontal: isTv ? 17 * tvScale : 16, vertical: isTv ? 9 * tvScale : 0),
),
child: playButtonLabel.isNotEmpty
? Row(
mainAxisSize: .min,
children: [
playButtonIcon,
SizedBox(width: isTv ? 7 * tvScale : 8),
Text(playButtonLabel, style: playTextStyle),
],
)
: playButtonIcon,
),
child: playButtonLabel.isNotEmpty
? Row(
mainAxisSize: .min,
children: [
playButtonIcon,
SizedBox(width: isTv ? 7 * tvScale : 8),
Text(playButtonLabel, style: playTextStyle),
],
)
: 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),
+1
View File
@@ -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
+194
View File
@@ -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()
+687
View File
@@ -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())
+141
View File
@@ -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())
+739
View File
@@ -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())
+140
View File
@@ -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()
+159
View File
@@ -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()
+138
View File
@@ -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()
+7
View File
@@ -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);
});
});