[verified] Publish ReelBox source under GPLv3
This commit is contained in:
commit
db2b505ca3
445 files changed
+105274
No files matched your search
@@ -0,0 +1,322 @@
|
||||
#!/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)
|
||||
Reference in new issue
Block a user