Files
plezy/scripts/test_run_maestro.py
T
edde746 0c58b2dc55 fix(ci): follow the guard roster and Flutter pin to their current homes
Two workflow guards had drifted from the code they describe, so
`scripts/ci_guard_checks.sh` failed on a clean tree.

The Flutter release-tag pin moved out of build.yml into the shared
setup-flutter-git composite action, but the checker read that action
from a fixed repository path while its test mutated a workflow fixture.
The mutation could not reach the checker, so the rejection test asserted
against an unmodified run. The checker now resolves the action beside
the workflow it is given, and the test materialises a `.github` tree so
the pin is genuinely exercised.

The script-test roster likewise moved into ci_guard_checks.sh, which
discovers `scripts/test_*.py` by glob; the dispatch guard still expected
each one to be named explicitly in ci_checks.sh and ci.yml. It now reads
that glob and checks both aggregates delegate to the shared roster.
2026-07-26 23:05:17 +02:00

439 lines
17 KiB
Python
Executable File

#!/usr/bin/env python3
from __future__ import annotations
import ast
from contextlib import redirect_stderr, redirect_stdout
from dataclasses import replace
import io
from pathlib import Path
import re
import shlex
import subprocess
import sys
import unittest
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(Path(__file__).resolve().parent))
import run_maestro # noqa: E402
import run_maestro_ci # noqa: E402
ROOT_DIR = Path(__file__).resolve().parent.parent
SCRIPTS_DIR = ROOT_DIR / "scripts"
LOCAL_SCRIPT_TEST_DISPATCHER = SCRIPTS_DIR / "ci_checks.sh"
CI_SCRIPT_TEST_DISPATCHER = ROOT_DIR / ".github/workflows/ci.yml"
# The guard roster both dispatchers above delegate to. It discovers the script
# tests by glob, so a new scripts/test_*.py is picked up without being listed.
GUARD_SCRIPT_TEST_DISPATCHER = SCRIPTS_DIR / "ci_guard_checks.sh"
E2E_WORKFLOW = ROOT_DIR / ".github/workflows/e2e.yml"
SCRIPT_TEST_DISPATCHERS = (
LOCAL_SCRIPT_TEST_DISPATCHER,
CI_SCRIPT_TEST_DISPATCHER,
GUARD_SCRIPT_TEST_DISPATCHER,
SCRIPTS_DIR / "ci_website_checks.sh",
)
REGRESSION_FLOWS_DIR = ROOT_DIR / ".maestro/regression_flows"
def _is_main_guard(expression: ast.expr) -> bool:
if (
not isinstance(expression, ast.Compare)
or len(expression.ops) != 1
or not isinstance(expression.ops[0], ast.Eq)
or len(expression.comparators) != 1
):
return False
left = expression.left
right = expression.comparators[0]
return (
isinstance(left, ast.Name)
and left.id == "__name__"
and isinstance(right, ast.Constant)
and right.value == "__main__"
) or (
isinstance(right, ast.Name)
and right.id == "__name__"
and isinstance(left, ast.Constant)
and left.value == "__main__"
)
def _executable_script_tests() -> set[str]:
executable = set()
for path in SCRIPTS_DIR.glob("test_*.py"):
module = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
if any(isinstance(node, ast.If) and _is_main_guard(node.test) for node in module.body):
executable.add(path.name)
return executable
def _dispatched_script_tests(path: Path) -> list[str]:
dispatched = []
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
# `for guard_test in scripts/test_*.py; do` dispatches the whole roster.
loop = re.match(r"for\s+\w+\s+in\s+(scripts/test_[^;\s]*\.py)\s*;?\s*(?:do)?$", stripped)
if loop:
dispatched.extend(sorted(match.name for match in ROOT_DIR.glob(loop.group(1))))
continue
try:
command = shlex.split(stripped, comments=True)
except ValueError:
continue
if len(command) < 2 or Path(command[0]).name not in {"python", "python3"}:
continue
script_name = Path(command[1]).name
if script_name.startswith("test_") and script_name.endswith(".py"):
dispatched.append(script_name)
return dispatched
def _dispatched_maestro_ci_targets(path: Path) -> set[str]:
targets = set()
for line in path.read_text(encoding="utf-8").splitlines():
try:
command = shlex.split(line.strip(), comments=True)
except ValueError:
continue
for index, token in enumerate(command[:-1]):
if Path(token).name == "run_maestro_ci.py":
targets.add(command[index + 1])
return targets
def _registered_regression_flows(
targets: dict[str, tuple[tuple[str, ...], ...]],
) -> set[str]:
registered = set()
for recipes in targets.values():
for arguments in recipes:
flow_target = run_maestro.parse_config(arguments, {}).flow_target
if flow_target.parent == REGRESSION_FLOWS_DIR:
registered.add(flow_target.relative_to(ROOT_DIR).as_posix())
return registered
class ScriptTestDispatchTests(unittest.TestCase):
def test_every_executable_script_test_has_a_dispatcher(self) -> None:
dispatched = {
script
for dispatcher in SCRIPT_TEST_DISPATCHERS
for script in _dispatched_script_tests(dispatcher)
}
self.assertSetEqual(dispatched, _executable_script_tests())
def test_real_jellyfin_fixture_test_is_in_local_and_ci_guards(self) -> None:
# Both aggregates reach the roster through the shared guard script, so
# the fixture test is covered exactly when the glob picks it up.
for dispatcher in (LOCAL_SCRIPT_TEST_DISPATCHER, CI_SCRIPT_TEST_DISPATCHER):
with self.subTest(dispatcher=dispatcher):
self.assertIn(
f"bash {GUARD_SCRIPT_TEST_DISPATCHER.relative_to(ROOT_DIR).as_posix()}",
dispatcher.read_text(encoding="utf-8"),
)
self.assertEqual(
_dispatched_script_tests(GUARD_SCRIPT_TEST_DISPATCHER).count("test_maestro_real_jellyfin.py"),
1,
)
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_offline_fault_exposes_host_proxy_control_url(self) -> None:
config = run_maestro.parse_config(["basic", "--fault", "offline", "--adb-reverse"], {})
runner = run_maestro.MaestroRunner(config)
runner.host_jellyfin_url = "http://127.0.0.1:8097"
runner.device_service_port = 8097
command = runner.maestro_command()
self.assertIn("JELLYFIN_URL=http://127.0.0.1:8097", command)
self.assertIn("JELLYFIN_CONTROL_URL=http://127.0.0.1:8097", command)
def test_flutter_build_enables_stable_physical_device_controls(self) -> None:
self.assertEqual(
run_maestro.flutter_build_command(),
(
"flutter",
"build",
"apk",
"--debug",
"--dart-define=PLEZY_MAESTRO_E2E=true",
),
)
def test_explicit_device_url_wins_over_network_mode(self) -> None:
config = run_maestro.parse_config(
["basic", "--adb-reverse", "--jellyfin-url", "http://device.test:9000"],
{},
)
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)
def test_health_wait_rejects_degraded_until_healthy(self) -> None:
runner = run_maestro.MaestroRunner(run_maestro.parse_config([], {}))
degraded = MagicMock()
degraded.__enter__.return_value.read.return_value = b"Degraded"
healthy = MagicMock()
healthy.__enter__.return_value.read.return_value = b" Healthy\n"
with (
patch.object(run_maestro.urllib.request, "urlopen", side_effect=[degraded, healthy]),
patch.object(run_maestro.time, "sleep") as sleep,
):
runner._wait_for_health("http://jellyfin.test", attempts=2, interval=0.25, service="Jellyfin")
sleep.assert_called_once_with(0.25)
class CiGroupTests(unittest.TestCase):
def test_android_15_group_runs_every_suite_after_failure(self) -> None:
expected_runs = len(run_maestro_ci.GROUPS["android-15"])
statuses = [0, 1, *([0] * (expected_runs - 2))]
with (
patch.object(run_maestro_ci.run_maestro, "main", side_effect=statuses) as run,
redirect_stdout(io.StringIO()),
):
exit_status = run_maestro_ci.run_group("android-15")
self.assertEqual(exit_status, 1)
self.assertEqual(run.call_count, expected_runs)
def test_android_15_instrumentation_has_an_isolated_target(self) -> None:
with (
patch.object(run_maestro_ci, "run_android_15_instrumentation") as instrumentation,
redirect_stdout(io.StringIO()),
):
exit_status = run_maestro_ci.run_target(run_maestro_ci.ANDROID_15_INSTRUMENTATION_TARGET)
self.assertEqual(exit_status, 0)
instrumentation.assert_called_once_with()
def test_all_automatic_and_manual_recipes_are_valid_runner_invocations(
self,
) -> None:
target_sets = (
("automatic", run_maestro_ci.GROUPS),
("destructive-manual", run_maestro_ci.DESTRUCTIVE_MANUAL_TARGETS),
)
for classification, targets in target_sets:
for target, recipes in targets.items():
for arguments in recipes:
with self.subTest(
classification=classification,
target=target,
arguments=arguments,
):
run_maestro.parse_config(arguments, {})
def test_top_level_regression_flow_inventory_is_complete(self) -> None:
inventory = {
path.relative_to(ROOT_DIR).as_posix()
for path in REGRESSION_FLOWS_DIR.glob("*.yaml")
}
automatic = _registered_regression_flows(run_maestro_ci.GROUPS)
destructive_manual = _registered_regression_flows(
run_maestro_ci.DESTRUCTIVE_MANUAL_TARGETS
)
self.assertFalse(automatic & destructive_manual)
self.assertSetEqual(automatic | destructive_manual, inventory)
def test_profile_regressions_are_only_in_the_destructive_manual_target(self) -> None:
profile_flows = {
".maestro/regression_flows/01_profile_switch_isolation.yaml",
".maestro/regression_flows/02_profile_teardown.yaml",
}
manual_profile_flows = _registered_regression_flows(
{
"profile-regressions": run_maestro_ci.DESTRUCTIVE_MANUAL_TARGETS[
"profile-regressions"
]
}
)
self.assertSetEqual(manual_profile_flows, profile_flows)
self.assertTrue(
profile_flows.isdisjoint(
_registered_regression_flows(run_maestro_ci.GROUPS)
)
)
def test_destructive_manual_targets_are_not_automatic_pr_targets(self) -> None:
automatic_pr_targets = _dispatched_maestro_ci_targets(E2E_WORKFLOW)
self.assertTrue(
set(run_maestro_ci.DESTRUCTIVE_MANUAL_TARGETS).isdisjoint(
automatic_pr_targets
)
)
def test_destructive_manual_recipes_have_distinct_diagnostics(self) -> None:
for target, recipes in run_maestro_ci.DESTRUCTIVE_MANUAL_TARGETS.items():
with self.subTest(target=target):
configs = [
run_maestro.parse_config(arguments, {}) for arguments in recipes
]
self.assertEqual(
len({config.jellyfin_log for config in configs}), len(configs)
)
self.assertEqual(
len({config.diagnostics_dir for config in configs}), len(configs)
)
def test_destructive_manual_target_requires_disposable_emulator_opt_in(
self,
) -> None:
with (
patch.object(run_maestro_ci.run_maestro, "main") as run,
redirect_stderr(io.StringIO()) as error_output,
):
exit_status = run_maestro_ci.main(["profile-regressions"])
self.assertEqual(exit_status, 2)
run.assert_not_called()
self.assertIn("Refusing destructive manual target", error_output.getvalue())
self.assertIn("--disposable-emulator", error_output.getvalue())
def test_destructive_manual_target_runs_after_explicit_opt_in(self) -> None:
recipes = run_maestro_ci.DESTRUCTIVE_MANUAL_TARGETS["profile-regressions"]
with (
patch.object(run_maestro_ci.run_maestro, "main", return_value=0) as run,
redirect_stdout(io.StringIO()),
):
exit_status = run_maestro_ci.main(
["profile-regressions", "--disposable-emulator"]
)
self.assertEqual(exit_status, 0)
self.assertEqual(
[invocation.args[0] for invocation in run.call_args_list], list(recipes)
)
def test_group_stops_after_interruption(self) -> None:
with (
patch.object(run_maestro_ci.run_maestro, "main", return_value=143) as run,
redirect_stdout(io.StringIO()),
):
exit_status = run_maestro_ci.run_group("android-15")
self.assertEqual(exit_status, 143)
run.assert_called_once_with(("basic",))
if __name__ == "__main__":
unittest.main()