#!/usr/bin/env python3 """Lean host Android-view tests, without the Android/Flutter Gradle build. Run: python3 tool/player_overlay_android_test.py --setup-only resolves reusable dependencies and exercises Android/Handler smoke tests. --test METHOD runs one production-overlay method; all methods run by default. All Gradle/compiler/JVM processes acquire the repository's .tooling.lock. Outputs, dependency manifests and JUnit XML live in scratch, not the app build. """ from __future__ import annotations import argparse import fcntl import hashlib import json import os from pathlib import Path import shutil import shlex import subprocess import sys import time import zipfile ROOT = Path(__file__).resolve().parents[1] WORK = Path('/root/.hermes/cache/scratch/reelbox-overlay-android-tests') CACHE = Path.home() / '.gradle/caches/modules-2/files-2.1' JDK = Path(os.environ.get('OVERLAY_TEST_JAVA_HOME', '/usr/lib/jvm/java-17-openjdk-amd64')) ANDROID = Path(os.environ.get('OVERLAY_TEST_ANDROID_JAR', '/root/developer-tools/android/platforms/android-36/android.jar')) LOCK = ROOT / '.tooling.lock' LOCK_FD: int | None = None BUILD = ''' import org.gradle.api.attributes.java.TargetJvmEnvironment plugins { id 'base' } repositories { maven { url = uri('https://repo.maven.apache.org/maven2') } google { content { includeGroupByRegex 'androidx[.].*' } } } configurations { harness { attributes { attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage, Usage.JAVA_RUNTIME)) attribute(TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE, objects.named(TargetJvmEnvironment, TargetJvmEnvironment.STANDARD_JVM)) } } } dependencies { harness('org.robolectric:robolectric:4.16.1') { // LEGACY graphics never loads the large native renderer distribution. exclude group: 'org.robolectric', module: 'nativeruntime-dist-compat' } harness 'junit:junit:4.13.2' } tasks.register('resolveHarness') { doLast { file('classpath.txt').text = configurations.harness.files.collect { it.absolutePath }.sort().join('\\n') + '\\n' } } ''' SMOKE = ''' package org.reelbox.reelbox; import android.app.Activity; import android.os.Handler; import android.os.Looper; import android.view.View; import android.widget.FrameLayout; import android.widget.Button; import java.time.Duration; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; import org.robolectric.annotation.LooperMode; import org.robolectric.annotation.GraphicsMode; import org.robolectric.Shadows; import static org.junit.Assert.*; @RunWith(RobolectricTestRunner.class) @Config(sdk = 28, manifest = Config.NONE, qualifiers = "w360dp-h780dp-mdpi") @LooperMode(LooperMode.Mode.PAUSED) @GraphicsMode(GraphicsMode.Mode.LEGACY) public class OverlayHarnessSmokeTest { @Test public void realAndroidLayoutClickAndMainHandler() { try (var controller = Robolectric.buildActivity(Activity.class).setup().visible()) { Activity activity = controller.get(); FrameLayout root = new FrameLayout(activity); Button button = new Button(activity); root.addView(button, new FrameLayout.LayoutParams(120, 48)); activity.setContentView(root); root.measure(View.MeasureSpec.makeMeasureSpec(360, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(780, View.MeasureSpec.EXACTLY)); root.layout(0, 0, 360, 780); assertEquals(120, button.getWidth()); assertEquals(48, button.getHeight()); int[] clicks = {0}; button.setOnClickListener(v -> clicks[0]++); assertTrue(button.performClick()); assertEquals(1, clicks[0]); new Handler(Looper.getMainLooper()).postDelayed(() -> button.setVisibility(View.GONE), 3000); Shadows.shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(2999)); assertEquals(View.VISIBLE, button.getVisibility()); Shadows.shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(1)); assertEquals(View.GONE, button.getVisibility()); } } } ''' RUNNER = ''' package org.reelbox.reelbox; import java.io.File; import java.io.FileOutputStream; import java.nio.charset.StandardCharsets; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import org.junit.runner.JUnitCore; import org.junit.runner.Request; import org.junit.runner.Result; import org.junit.runner.notification.Failure; import org.junit.runner.notification.RunListener; public class OverlayHostRunner { public static void main(String[] args) throws Exception { JUnitCore core = new JUnitCore(); core.addListener(new RunListener() { @Override public void testStarted(org.junit.runner.Description d) { System.out.println("RUN " + d.getMethodName()); } @Override public void testFailure(Failure f) { System.err.println("FAIL " + f.getTestHeader() + "\\n" + f.getTrace()); } }); Class clazz = Class.forName(args[0]); Result result = core.run(args.length > 2 ? Request.method(clazz, args[2]) : Request.aClass(clazz)); var doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument(); var suite = doc.createElement("testsuite"); suite.setAttribute("name", args[0]); suite.setAttribute("tests", Integer.toString(result.getRunCount())); suite.setAttribute("failures", Integer.toString(result.getFailureCount())); suite.setAttribute("skipped", Integer.toString(result.getIgnoreCount())); suite.setAttribute("time", Double.toString(result.getRunTime() / 1000.0)); doc.appendChild(suite); for (Failure failure : result.getFailures()) { var test = doc.createElement("testcase"); test.setAttribute("name", failure.getTestHeader()); var fail = doc.createElement("failure"); fail.setAttribute("message", String.valueOf(failure.getMessage())); fail.setTextContent(failure.getTrace()); test.appendChild(fail); suite.appendChild(test); } TransformerFactory.newInstance().newTransformer().transform(new DOMSource(doc), new StreamResult(new File(args[1]))); System.out.printf("RESULT tests=%d failures=%d ignored=%d elapsed_ms=%d%n", result.getRunCount(), result.getFailureCount(), result.getIgnoreCount(), result.getRunTime()); System.exit(result.wasSuccessful() && result.getRunCount() > 0 ? 0 : 1); } } ''' def run(command: list[str], *, log: str | None = None) -> None: env = dict(os.environ, JAVA_HOME=str(JDK), TMPDIR=str(WORK / 'tmp')) env['PATH'] = str(JDK / 'bin') + os.pathsep + env.get('PATH', '') display = command.copy() for flag in ('-cp', '-classpath'): if flag in display: index = display.index(flag) + 1 display[index] = f'<{len(display[index].split(os.pathsep))} classpath entries>' print('+', shlex.join(display), flush=True) assert LOCK_FD is not None, 'Heavy invocations must hold the tooling flock' with (WORK / 'commands.log').open('a') as command_log: command_log.write(shlex.join(command) + '\n') # Inherit the locked descriptor so a timed-out parent cannot unlock a live JVM. proc = subprocess.Popen(command, cwd=WORK, env=env, pass_fds=(LOCK_FD,), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) assert proc.stdout is not None log_path = WORK / (log or 'last-command.log') if log_path.exists(): log_path.rename(log_path.with_name(log_path.name + '.' + str(time.time_ns()))) with log_path.open('w') as output: for line in proc.stdout: print(line, end='', flush=True) output.write(line) if proc.wait(): raise subprocess.CalledProcessError(proc.returncode, command) def cached_jar(artifact: str, version: str = '*') -> Path: paths = sorted((CACHE / artifact).glob(version + '/*/*.jar')) if not paths: raise RuntimeError(f'Missing cached compiler dependency: {artifact}:{version}') return paths[-1] def resolve() -> list[Path]: build = WORK / 'build.gradle' fingerprint = hashlib.sha256(BUILD.encode()).hexdigest() stamp = WORK / 'dependency-fingerprint' manifest = WORK / 'classpath.txt' fresh = stamp.exists() and stamp.read_text() == fingerprint and manifest.exists() if fresh: fresh = all(Path(line).exists() for line in manifest.read_text().splitlines()) if not fresh: build.write_text(BUILD) (WORK / 'settings.gradle').write_text("rootProject.name = 'reelbox-overlay-host-tests'\n") (WORK / 'gradle.properties').write_text('org.gradle.jvmargs=-Xmx256m -XX:MaxMetaspaceSize=192m\norg.gradle.workers.max=1\norg.gradle.daemon=false\norg.gradle.vfs.watch=false\n') candidates = sorted((Path.home() / '.gradle/wrapper/dists').glob('gradle-9.3.1-bin/*/gradle-9.3.1/bin/gradle')) gradle = os.environ.get('OVERLAY_TEST_GRADLE') or (str(candidates[0]) if candidates else shutil.which('gradle')) if not gradle: raise RuntimeError('No Gradle 9.3.1 distribution found; set OVERLAY_TEST_GRADLE') run([gradle, '--no-daemon', '--max-workers=1', '--console=plain', 'resolveHarness'], log='dependency-resolution.log') stamp.write_text(fingerprint) jars = [] for line in manifest.read_text().splitlines(): path = Path(line) if path.suffix == '.aar': destination = WORK / 'aar-jars' / (path.stem + '-classes.jar') destination.parent.mkdir(exist_ok=True) if not destination.exists(): with zipfile.ZipFile(path) as aar: destination.write_bytes(aar.read('classes.jar')) jars.append(destination) else: jars.append(path) return jars def main() -> int: global LOCK_FD parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--setup-only', action='store_true') parser.add_argument('--test', help='Run one PlayerOverlayAndroidTest method') args = parser.parse_args() WORK.mkdir(parents=True, exist_ok=True) (WORK / 'tmp').mkdir(exist_ok=True) # Serialize the complete small pipeline, not separate JVMs that let a full # app verification jump into the middle of compilation and test execution. print(f'Acquiring flock {LOCK}', flush=True) lock_file = LOCK.open('a') fcntl.flock(lock_file, fcntl.LOCK_EX) LOCK_FD = lock_file.fileno() if not (JDK / 'bin/java').is_file() or not ANDROID.is_file(): raise RuntimeError('JDK17 or Android compile jar missing; see environment overrides in script') deps = resolve() stdlib = cached_jar('org.jetbrains.kotlin/kotlin-stdlib', '2.4.0') sources = WORK / 'sources' sources.mkdir(exist_ok=True) (sources / 'OverlayHarnessSmokeTest.java').write_text(SMOKE) (sources / 'OverlayHostRunner.java').write_text(RUNNER) classes = WORK / 'classes' if classes.exists(): shutil.rmtree(classes) classes.mkdir() cp = os.pathsep.join(map(str, [classes, ANDROID, stdlib, *deps])) javac = [str(JDK / 'bin/javac'), '-J-Xmx192m', '--release', '17', '-cp', cp, '-d', str(classes)] run([*javac, str(sources / 'OverlayHarnessSmokeTest.java'), str(sources / 'OverlayHostRunner.java')], log='javac-smoke.log') opens = ['java.base/java.lang', 'java.base/java.util', 'java.base/java.io', 'java.base/java.net', 'java.base/java.security', 'java.base/java.text', 'java.base/jdk.internal.access', 'java.desktop/java.awt.font', 'jdk.compiler/com.sun.tools.javac.api'] java = [str(JDK / 'bin/java'), '-Xmx384m', '-XX:MaxMetaspaceSize=192m', f'-Djava.io.tmpdir={WORK / "tmp"}', '-Drobolectric.dependency.repo.url=https://repo.maven.apache.org/maven2', f'-Dmaven.repo.local={WORK / "m2"}', '-Drobolectric.graphicsMode=LEGACY', *['--add-opens=' + item + '=ALL-UNNAMED' for item in opens], '-cp', cp, 'org.reelbox.reelbox.OverlayHostRunner'] # The SDK resolver downloads only the requested SDK28 instrumented Android jar. # A separate smoke run establishes that infrastructure errors aren't overlay failures. run([*java, 'org.reelbox.reelbox.OverlayHarnessSmokeTest', str(WORK / 'smoke-results.xml')], log='smoke.log') if args.setup_only: print('SETUP VERIFIED: SDK28 / Java17 / Robolectric4.16.1 / PAUSED / LEGACY. No production-overlay tests requested.') return 0 source = ROOT / 'android/app/src/main/kotlin/org/reelbox/reelbox/PlayerOverlayView.kt' if not source.exists(): print(f'BLOCKED: production overlay does not exist yet: {source}', file=sys.stderr) return 2 compiler = [cached_jar('org.jetbrains.kotlin/kotlin-compiler-embeddable', '2.4.0'), stdlib, cached_jar('org.jetbrains.kotlin/kotlin-script-runtime', '2.4.0'), cached_jar('org.jetbrains.kotlin/kotlin-reflect'), cached_jar('org.jetbrains.kotlinx/kotlinx-coroutines-core-jvm'), cached_jar('org.jetbrains/annotations')] trove = sorted((CACHE / 'org.jetbrains.intellij.deps/trove4j').glob('*/*/*.jar')) compiler += trove[-1:] # Compile real production source, never a replacement or copied overlay implementation. overlay_sources = [source, source.with_name('PlayerControlsState.kt')] state_source = source.with_name('PlayerOverlayState.kt') if state_source.exists(): overlay_sources.append(state_source) test = ROOT / 'android/app/src/test/java/org/reelbox/reelbox/PlayerOverlayAndroidTest.java' input_hashes = {str(path): hashlib.sha256(path.read_bytes()).hexdigest() for path in [*overlay_sources, test]} (WORK / 'tested-inputs.json').write_text(json.dumps({ 'robolectric': '4.16.1', 'runtime_sdk': 28, 'java': 17, 'looper': 'PAUSED', 'graphics': 'LEGACY', 'sha256': input_hashes, }, indent=2) + '\n') run([str(JDK / 'bin/java'), '-Xmx256m', f'-Djava.io.tmpdir={WORK / "tmp"}', '-cp', os.pathsep.join(map(str, compiler)), 'org.jetbrains.kotlin.cli.jvm.K2JVMCompiler', '-jvm-target', '17', '-no-stdlib', '-no-reflect', '-classpath', cp, '-d', str(classes), *map(str, overlay_sources)], log='kotlin-compile.log') run([*javac, str(test)], log='javac-overlay.log') command = [*java, 'org.reelbox.reelbox.PlayerOverlayAndroidTest', str(WORK / 'overlay-results.xml')] if args.test: command.append(args.test) run(command, log='overlay.log') for path, digest in input_hashes.items(): if hashlib.sha256(Path(path).read_bytes()).hexdigest() != digest: raise RuntimeError(f'Source changed during test execution; rerun before claiming verification: {path}') print('Verified real Android view events, Handler timing and layout on host LEGACY graphics; not a device pixel/rendering test.') return 0 if __name__ == '__main__': try: sys.exit(main()) except subprocess.CalledProcessError as error: print(f'FAILED: command exited {error.returncode}; logs: {WORK}', file=sys.stderr) sys.exit(error.returncode) except (OSError, RuntimeError) as error: print(f'BLOCKED: {error}', file=sys.stderr) sys.exit(2)