tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

test_android_gradle_build.py (12872B)


      1 # This Source Code Form is subject to the terms of the Mozilla Public
      2 # License, v. 2.0. If a copy of the MPL was not distributed with this
      3 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
      4 
      5 import hashlib
      6 import json
      7 import logging
      8 import os
      9 import sys
     10 import textwrap
     11 from collections import defaultdict
     12 from pathlib import Path
     13 
     14 import mozunit
     15 import pytest
     16 from buildconfig import topsrcdir
     17 from mach.util import get_state_dir
     18 from mozpack.files import JarFinder
     19 from mozpack.mozjar import JarReader
     20 from mozprocess import ProcessHandler
     21 
     22 logger = logging.getLogger(__name__)
     23 
     24 
     25 @pytest.fixture(scope="session")
     26 def test_dir():
     27    return (
     28        Path(get_state_dir(specific_to_topsrcdir=True, topsrcdir=topsrcdir))
     29        / "android-gradle-build"
     30    )
     31 
     32 
     33 @pytest.fixture(scope="session")
     34 def objdir(test_dir):
     35    return test_dir / "objdir"
     36 
     37 
     38 @pytest.fixture(scope="session")
     39 def mozconfig(test_dir, objdir):
     40    mozconfig_path = test_dir / "mozconfig"
     41    mozconfig_path.parent.mkdir(parents=True, exist_ok=True)
     42    mozconfig_path.write_text(
     43        textwrap.dedent(
     44            f"""
     45                ac_add_options --enable-application=mobile/android
     46                ac_add_options --enable-artifact-builds
     47                ac_add_options --target=arm
     48                mk_add_options MOZ_OBJDIR="{objdir}"
     49                export GRADLE_FLAGS="-PbuildMetrics -PbuildMetricsFileSuffix=test"
     50            """
     51        )
     52    )
     53    return mozconfig_path
     54 
     55 
     56 @pytest.fixture(scope="session")
     57 def run_mach(mozconfig):
     58    def inner(argv, cwd=None):
     59        env = os.environ.copy()
     60        env["MOZCONFIG"] = str(mozconfig)
     61        env["MACH_NO_TERMINAL_FOOTER"] = "1"
     62        env["MACH_NO_WRITE_TIMES"] = "1"
     63 
     64        if os.environ.get("MOZ_AUTOMATION"):
     65            env["MACH_BUILD_PYTHON_NATIVE_PACKAGE_SOURCE"] = "system"
     66 
     67        def pol(line):
     68            logger.debug(line)
     69 
     70        proc = ProcessHandler(
     71            [sys.executable, "mach"] + argv,
     72            env=env,
     73            cwd=cwd or topsrcdir,
     74            processOutputLine=pol,
     75            universal_newlines=True,
     76        )
     77        proc.run()
     78        proc.wait()
     79 
     80        return proc.poll(), proc.output
     81 
     82    return inner
     83 
     84 
     85 AARS = {
     86    "geckoview.aar": "gradle/build/mobile/android/geckoview/outputs/aar/geckoview-debug.aar",
     87 }
     88 
     89 
     90 APKS = {
     91    "test_runner.apk": "gradle/build/mobile/android/test_runner/outputs/apk/debug/test_runner-debug.apk",
     92    "androidTest": "gradle/build/mobile/android/geckoview/outputs/apk/androidTest/debug/geckoview-debug-androidTest.apk",
     93    "geckoview_example.apk": "gradle/build/mobile/android/geckoview_example/outputs/apk/debug/geckoview_example-debug.apk",
     94    "messaging_example.apk": "gradle/build/mobile/android/examples/messaging_example/app/outputs/apk/debug/messaging_example-debug.apk",
     95    "port_messaging_example.apk": "gradle/build/mobile/android/examples/port_messaging_example/app/outputs/apk/debug/port_messaging_example-debug.apk",
     96 }
     97 
     98 
     99 def hashes(objdir, pattern, targets={**AARS, **APKS}):
    100    target_to_hash = {}
    101    hash_to_target = defaultdict(list)
    102    for shortname, target in targets.items():
    103        finder = JarFinder(target, JarReader(str(objdir / target)))
    104        hasher = hashlib.blake2b()
    105 
    106        # We sort paths.  This allows a pattern like `classes*.dex` to capture
    107        # changes to any of the DEX files, no matter how they are ordered in an
    108        # AAR or APK.
    109        for p, f in sorted(finder.find(pattern), key=lambda x: x[0]):
    110            fp = f.open()
    111            while True:
    112                data = fp.read(8192)
    113                if not len(data):
    114                    break
    115                hasher.update(data)
    116 
    117        h = hasher.hexdigest()
    118        target_to_hash[shortname] = h
    119        hash_to_target[h].append(shortname)
    120 
    121    return target_to_hash, hash_to_target
    122 
    123 
    124 def get_test_run_build_metrics(objdir):
    125    """Find and load the build-metrics JSON file for our test run."""
    126    log_dir = objdir / "gradle" / "build" / "metrics"
    127    if not log_dir.exists():
    128        return None
    129 
    130    suffix = "test"
    131    build_metrics_file = log_dir / f"build-metrics-{suffix}.json"
    132 
    133    try:
    134        with build_metrics_file.open(encoding="utf-8") as f:
    135            return json.load(f)
    136    except (json.JSONDecodeError, OSError) as e:
    137        logger.warning(f"Failed to load build metrics from {build_metrics_file}: {e}")
    138        return None
    139 
    140 
    141 def assert_success(returncode, output):
    142    """Assert that a command succeeded, showing output on failure."""
    143    if returncode != 0:
    144        output_lines = output if isinstance(output, list) else output.splitlines()
    145 
    146        if os.environ.get("MOZ_AUTOMATION"):
    147            final_output = "\n".join(output_lines)
    148        else:
    149            tail_lines = (
    150                output_lines[-100:] if len(output_lines) > 100 else output_lines
    151            )
    152            final_output = (
    153                f"Last {len(tail_lines)} of {len(output_lines)} lines of output:\n\n"
    154                + "\n".join(tail_lines)
    155            )
    156        pytest.fail(f"Command failed with return code: {returncode}\n{final_output}")
    157 
    158 
    159 def assert_all_task_statuses(objdir, acceptable_statuses, always_executed_tasks=None):
    160    """Asserts that all tasks in build metrics have acceptable statuses."""
    161 
    162    if always_executed_tasks is None:
    163        always_executed_tasks = [
    164            ":machBuildFaster",
    165            ":machStagePackage",
    166            # Always executes because it depends on assets from ${topobjdir}/dist/geckoview/assets
    167            # which get timestamps updated by the mach tasks above. Takes 0.000 seconds so not
    168            # a performance issue, but will be resolved when mach tasks get proper Gradle dependencies.
    169            ":geckoview:generateDebugAssets",
    170            # Always executes because suppressUselessCastInSafeArgs sets `outputs.upToDateWhen { false }`.
    171            # We could try using a marker file otherwise, but the task runtime is negligible and the added
    172            # complexity doesn't seem worth it for what should only be a short-term workaround until Google
    173            # fixes the upstream Navigation bug that led to it being added in the first place.
    174            ":fenix:generateSafeArgsDebug",
    175            ":fenix:suppressUselessCastInSafeArgs",
    176        ]
    177 
    178    build_metrics = get_test_run_build_metrics(objdir)
    179    assert build_metrics is not None, "Build metrics JSON not found"
    180    assert "tasks" in build_metrics, "Build metrics missing 'tasks' section"
    181 
    182    metrics_tasks = build_metrics.get("tasks", [])
    183 
    184    for task in metrics_tasks:
    185        task_name = task.get("path")
    186        actual_status = task.get("status")
    187 
    188        if task_name in always_executed_tasks:
    189            assert actual_status == "EXECUTED", (
    190                f"Task {task_name} should always execute, got '{actual_status}'"
    191            )
    192        else:
    193            assert actual_status in acceptable_statuses, (
    194                f"Task {task_name} had status '{actual_status}', expected one of {acceptable_statuses}"
    195            )
    196 
    197 
    198 def assert_ordered_task_outcomes(objdir, ordered_expected_task_statuses):
    199    """Takes a list of (task_name, expected_status) tuples and verifies that they appear
    200    in the build metrics in the same order with the expected statuses.
    201    """
    202    # Get build metrics and fail if not found
    203    build_metrics = get_test_run_build_metrics(objdir)
    204    assert build_metrics is not None, "Build metrics JSON not found"
    205    assert "tasks" in build_metrics, "Build metrics missing 'tasks' section"
    206 
    207    # Extract tasks from metrics in order
    208    metrics_tasks = build_metrics.get("tasks", [])
    209    expected_task_names = {task_name for task_name, _ in ordered_expected_task_statuses}
    210    task_order = [
    211        task.get("path")
    212        for task in metrics_tasks
    213        if task.get("path") in expected_task_names
    214    ]
    215    expected_order = [task_name for task_name, _ in ordered_expected_task_statuses]
    216 
    217    # Check that all expected tasks were found
    218    missing_tasks = expected_task_names - set(task_order)
    219    assert not missing_tasks, f"Tasks not found in build metrics: {missing_tasks}"
    220 
    221    # Check order matches expectation
    222    assert task_order == expected_order, (
    223        f"Task execution order mismatch. Expected: {expected_order}, Got: {task_order}"
    224    )
    225 
    226    # Check statuses for each task
    227    task_lookup = {task.get("path"): task for task in metrics_tasks}
    228    for task_name, expected_status in ordered_expected_task_statuses:
    229        task_info = task_lookup[task_name]
    230        actual_status = task_info.get("status")
    231        assert actual_status == expected_status, (
    232            f"Task {task_name} had status '{actual_status}', expected '{expected_status}'"
    233        )
    234 
    235 
    236 def test_artifact_build(objdir, mozconfig, run_mach):
    237    assert_success(*run_mach(["build"]))
    238    # Order matters, since `mach build stage-package` depends on the
    239    # outputs of `mach build faster`.
    240    assert_ordered_task_outcomes(
    241        objdir, [(":machBuildFaster", "SKIPPED"), (":machStagePackage", "SKIPPED")]
    242    )
    243 
    244    _, omnijar_hash_to = hashes(objdir, "assets/omni.ja")
    245    assert len(omnijar_hash_to) == 1
    246    (omnijar_hash_orig,) = omnijar_hash_to.values()
    247 
    248    assert_success(*run_mach(["gradle", "geckoview_example:assembleDebug"]))
    249    # Order matters, since `mach build stage-package` depends on the
    250    # outputs of `mach build faster`.
    251    assert_ordered_task_outcomes(
    252        objdir, [(":machBuildFaster", "EXECUTED"), (":machStagePackage", "EXECUTED")]
    253    )
    254 
    255    _, omnijar_hash_to = hashes(objdir, "assets/omni.ja")
    256    assert len(omnijar_hash_to) == 1
    257    (omnijar_hash_new,) = omnijar_hash_to.values()
    258 
    259    assert omnijar_hash_orig == omnijar_hash_new
    260 
    261 
    262 def test_minify_fenix_incremental_build(objdir, mozconfig, run_mach):
    263    """Verify that minifyReleaseWithR8 is UP-TO-DATE on a subsequent
    264    run when there are no code changes.
    265    """
    266 
    267    # Ensure a clean state
    268    assert_success(*run_mach(["gradle", ":fenix:cleanMinifyReleaseWithR8"]))
    269    assert_success(*run_mach(["gradle", ":fenix:minifyReleaseWithR8"]))
    270    assert_ordered_task_outcomes(objdir, [(":fenix:minifyReleaseWithR8", "EXECUTED")])
    271 
    272    assert_success(*run_mach(["gradle", ":fenix:minifyReleaseWithR8"]))
    273    assert_ordered_task_outcomes(objdir, [(":fenix:minifyReleaseWithR8", "UP-TO-DATE")])
    274 
    275 
    276 def test_geckoview_build(objdir, mozconfig, run_mach):
    277    assert_success(*run_mach(["build"]))
    278    assert_success(*run_mach(["gradle", "geckoview:clean"]))
    279    assert_success(*run_mach(["gradle", "geckoview:assembleDebug"]))
    280    assert_all_task_statuses(objdir, ["EXECUTED", "UP-TO-DATE", "SKIPPED"])
    281 
    282    assert_success(*run_mach(["gradle", "geckoview:assembleDebug"]))
    283    assert_all_task_statuses(objdir, ["UP-TO-DATE", "SKIPPED"])
    284 
    285 
    286 def test_fenix_build(objdir, mozconfig, run_mach):
    287    assert_success(*run_mach(["build"]))
    288    assert_success(
    289        *run_mach(["gradle", "fenix:clean", ":components:support-base:clean"])
    290    )
    291    assert_success(*run_mach(["gradle", "fenix:assembleDebug"]))
    292    assert_ordered_task_outcomes(
    293        objdir, [(":components:support-base:generateComponentEnum", "EXECUTED")]
    294    )
    295    assert_all_task_statuses(objdir, ["EXECUTED", "UP-TO-DATE", "SKIPPED"])
    296 
    297    assert_success(*run_mach(["gradle", "fenix:assembleDebug"]))
    298    assert_ordered_task_outcomes(
    299        objdir, [(":components:support-base:generateComponentEnum", "UP-TO-DATE")]
    300    )
    301    assert_all_task_statuses(objdir, ["UP-TO-DATE", "SKIPPED"])
    302 
    303 
    304 def test_focus_build(objdir, mozconfig, run_mach):
    305    assert_success(*run_mach(["build"]))
    306    assert_success(*run_mach(["gradle", "focus:clean"]))
    307    assert_success(*run_mach(["gradle", "focus:assembleDebug"]))
    308    assert_ordered_task_outcomes(
    309        objdir, [(":focus-android:generateLocaleList", "EXECUTED")]
    310    )
    311    assert_all_task_statuses(objdir, ["EXECUTED", "UP-TO-DATE", "SKIPPED"])
    312 
    313    assert_success(*run_mach(["gradle", "focus:assembleDebug"]))
    314    assert_ordered_task_outcomes(
    315        objdir, [(":focus-android:generateLocaleList", "UP-TO-DATE")]
    316    )
    317    assert_all_task_statuses(objdir, ["UP-TO-DATE", "SKIPPED"])
    318 
    319 
    320 def test_android_export(objdir, mozconfig, run_mach):
    321    # To ensure a consistent state, we delete the marker file
    322    # to force the :verifyGleanVersion task to re-run.
    323    marker_file = objdir / "gradle" / "build" / "glean" / "verifyGleanVersion.marker"
    324    marker_file.unlink(missing_ok=True)
    325 
    326    bindings_dir = Path(topsrcdir) / "widget" / "android" / "bindings"
    327    inputs = list(bindings_dir.glob("*-classes.txt"))
    328 
    329    assert_success(*run_mach(["android", "export"] + [str(f) for f in inputs]))
    330    assert_ordered_task_outcomes(objdir, [(":verifyGleanVersion", "EXECUTED")])
    331 
    332    assert_success(*run_mach(["android", "export"] + [str(f) for f in inputs]))
    333    assert_ordered_task_outcomes(objdir, [(":verifyGleanVersion", "UP-TO-DATE")])
    334 
    335 
    336 if __name__ == "__main__":
    337    mozunit.main()