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()