Files
plezy/scripts/run_maestro_ci.py
2026-08-10 20:28:41 +02:00

266 lines
8.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""Run automatic Maestro groups and guarded destructive manual targets."""
from __future__ import annotations
import argparse
import sys
from collections.abc import Sequence
import run_maestro
ANDROID_15_INSTRUMENTATION_CLASSES = (
"androidx.media3.decoder.ffmpeg.PlezyFfmpegPlaybackTest,"
"com.edde746.plezy.exoplayer.PlezyAudioModePlaybackTest"
)
ANDROID_15_INSTRUMENTATION_TARGET = "android-15-instrumentation"
# Separate R8 reachability from instrumentation: these suites exercise APIs the app
# does not call directly, so shrinking them is expected. Covers name-based reachability (#1703).
ANDROID_R8_REACHABILITY_CLASSES = "androidx.media3.decoder.ffmpeg.FfmpegDecoderReachabilityTest"
ANDROID_R8_REACHABILITY_TARGET = "android-r8-reachability"
GROUPS: dict[str, tuple[tuple[str, ...], ...]] = {
"android-15": (
("basic",),
("catalog",),
("media",),
(
"basic",
"--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",
),
(
"basic",
"--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",
),
(
"basic",
"--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",
),
(
"basic",
"--flow",
".maestro/regression_flows/07_sheet_back_dismiss.yaml",
"--jellyfin-log",
"build/maestro-sheets/back-dismiss.log",
"--diagnostics-dir",
"build/maestro-sheets/back-dismiss-diagnostics",
),
(
"basic",
"--flow",
".maestro/regression_flows/08_track_choice_survives_pending_pass.yaml",
"--jellyfin-log",
"build/maestro-tracks/pending-pass.log",
"--diagnostics-dir",
"build/maestro-tracks/pending-pass-diagnostics",
),
(
"basic",
"--flow",
".maestro/regression_flows/09_language_picker_locales.yaml",
"--jellyfin-log",
"build/maestro-i18n/language-picker.log",
"--diagnostics-dir",
"build/maestro-i18n/language-picker-diagnostics",
),
(
"basic",
"--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",
),
(
"basic",
"--fault",
"offline",
"--flow",
".maestro/flows/09_download_offline_playback.yaml",
"--jellyfin-log",
"build/maestro-offline/jellyfin.log",
"--proxy-log",
"build/maestro-offline/jellyfin-proxy.log",
"--proxy-journal",
"build/maestro-offline/proxy-journal.jsonl",
"--diagnostics-dir",
"build/maestro-offline/diagnostics",
),
(
"basic",
"--fault",
"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",
),
),
"android-9": (
(
"basic",
# API 28 uses adb reverse because 10.0.2.2 routing is unreliable here.
"--adb-reverse",
"--flow",
".maestro/flows/05_playback.yaml",
"--jellyfin-log",
"build/maestro-legacy/jellyfin.log",
"--diagnostics-dir",
"build/maestro-legacy/diagnostics",
),
),
# Manual Android TV hardware target; workflow runs phone-emulator TV regressions.
"android-tv-device": (
(
"basic",
"--adb-reverse",
"--flow",
".maestro/regression_flows/10_tv_settings_navigation.yaml",
"--jellyfin-log",
"build/maestro-tv-device/settings-navigation.log",
"--diagnostics-dir",
"build/maestro-tv-device/settings-navigation-diagnostics",
),
),
}
DESTRUCTIVE_MANUAL_TARGETS: dict[str, tuple[tuple[str, ...], ...]] = {
"profile-regressions": (
(
"basic",
"--flow",
".maestro/regression_flows/01_profile_switch_isolation.yaml",
"--jellyfin-log",
"build/maestro-profile-regressions/profile-switch-isolation-jellyfin.log",
"--diagnostics-dir",
"build/maestro-profile-regressions/profile-switch-isolation-diagnostics",
),
(
"basic",
"--flow",
".maestro/regression_flows/02_profile_teardown.yaml",
"--jellyfin-log",
"build/maestro-profile-regressions/profile-teardown-jellyfin.log",
"--diagnostics-dir",
"build/maestro-profile-regressions/profile-teardown-diagnostics",
),
),
}
def run_android_15_instrumentation() -> None:
print("==> Android 15 filtered instrumentation", flush=True)
run_maestro._run_checked(
(
"android/gradlew",
"-p",
"android",
":app:connectedDebugAndroidTest",
"-x",
":app:compileFlutterBuildDebug",
f"-Pandroid.testInstrumentationRunnerArguments.class={ANDROID_15_INSTRUMENTATION_CLASSES}",
)
)
def run_android_r8_reachability() -> None:
print("==> Android R8 reachability", flush=True)
# Run minified instrumentation to catch reflective, JNI, and native-load keep
# regressions. Do not exclude the Flutter build; CI only prebuilds debug.
run_maestro._run_checked(
(
"android/gradlew",
"-p",
"android",
":app:connectedMinifiedAndroidTest",
"-Pplezy.testBuildType=minified",
f"-Pandroid.testInstrumentationRunnerArguments.class={ANDROID_R8_REACHABILITY_CLASSES}",
)
)
def run_recipes(recipes: tuple[tuple[str, ...], ...]) -> int:
failed = False
for arguments in recipes:
print(f"==> Maestro {' '.join(arguments)}", flush=True)
exit_status = run_maestro.main(arguments)
if exit_status >= 128:
return exit_status
failed = failed or exit_status != 0
return 1 if failed else 0
def run_group(name: str) -> int:
return run_recipes(GROUPS[name])
def run_target(name: str, *, disposable_emulator: bool = False) -> int:
if name in DESTRUCTIVE_MANUAL_TARGETS:
if not disposable_emulator:
print(
f"Refusing destructive manual target {name!r}: "
"re-run with --disposable-emulator only on a disposable emulator.",
file=sys.stderr,
)
return 2
print(f"==> DESTRUCTIVE manual Maestro target: {name}", flush=True)
return run_recipes(DESTRUCTIVE_MANUAL_TARGETS[name])
if name == ANDROID_15_INSTRUMENTATION_TARGET:
run_android_15_instrumentation()
return 0
if name == ANDROID_R8_REACHABILITY_TARGET:
run_android_r8_reachability()
return 0
return run_group(name)
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"target",
choices=(
*GROUPS,
ANDROID_15_INSTRUMENTATION_TARGET,
ANDROID_R8_REACHABILITY_TARGET,
*DESTRUCTIVE_MANUAL_TARGETS,
),
)
parser.add_argument(
"--disposable-emulator",
action="store_true",
help="opt in to a destructive manual target on a disposable emulator",
)
args = parser.parse_args(argv)
return run_target(args.target, disposable_emulator=args.disposable_emulator)
if __name__ == "__main__":
raise SystemExit(main())