Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
103f97e
Update to Android Gradle plugin version 8.4.2
mhsmith Jul 10, 2024
fb1b54a
Separate Python test runner from MainActivity so it can be run from a…
mhsmith Jul 10, 2024
b99b769
Add `android.py test` command
mhsmith Jul 10, 2024
6f07a39
--connected mode working
mhsmith Jul 23, 2024
c71f3cf
--managed mode working, and add instructions to README
mhsmith Jul 23, 2024
cd27b0f
Clarifications
mhsmith Jul 23, 2024
6bbdfda
Restore build command logging, and only require adb when running tests
mhsmith Jul 23, 2024
e738689
Make testbed include only those ABIs which have been built
mhsmith Jul 30, 2024
4561930
Merge branch 'android-2024-07' into android-test-script
mhsmith Jul 30, 2024
b519874
Fix Windows issues
mhsmith Jul 31, 2024
b3ed4c7
Merge branch 'main' into android-test-script
mhsmith Aug 5, 2024
32f79d7
Clarify documentation; remove default `python -m test` arguments
mhsmith Aug 8, 2024
6471512
Link to page about test options; add newlines at end of files
mhsmith Aug 8, 2024
5c3967e
Add `android.py build-testbed` command
mhsmith Aug 8, 2024
cb60cc4
If test script fails before logcat starts, show the Gradle output eve…
mhsmith Aug 11, 2024
b075842
Fix logcat error messages being hidden
mhsmith Aug 11, 2024
2266e82
Improve logging, add timeouts
mhsmith Aug 11, 2024
397d20b
Make the app use the same NDK version as the Python build
mhsmith Aug 11, 2024
8e80dd2
Install platform-tools automatically
mhsmith Aug 11, 2024
709061e
In --connected mode, uninstall app before running Gradle
mhsmith Aug 12, 2024
756bc13
Handle adb logcat returning failure when device disconnects
mhsmith Aug 12, 2024
6b2ed6c
Filter logs by PID rather than UID
mhsmith Aug 12, 2024
05a26cc
Try to terminate subprocesses with SIGTERM before sending SIGKILL
mhsmith Aug 12, 2024
a0dd03b
Handle SIGTERM the same way as SIGINT
mhsmith Aug 12, 2024
2d53549
Fix race condition with pre-run uninstall
mhsmith Aug 12, 2024
c9d5bf5
Fix race condition in a more efficient way
mhsmith Aug 12, 2024
706a1da
Make testbed pick up edits to pure-Python files in the Lib directory
mhsmith Aug 12, 2024
ae3a460
Merge branch 'main' into android-test-script
mhsmith Aug 12, 2024
cf15b99
Remove `boot_completed`, which is unnecessary since we're no longer r…
mhsmith Aug 13, 2024
c56373a
Automatically accept SDK licenses, and log Gradle package installatio…
mhsmith Aug 13, 2024
1e89e58
Stop Gradle test tasks being skipped as up to date
mhsmith Aug 13, 2024
91f356b
Fix CalledProcessError formatting
mhsmith Aug 13, 2024
bdaad24
Add note about testing on Windows
mhsmith Aug 13, 2024
ca2bae2
Implement `fileno` method on stdout and stderr
mhsmith Aug 13, 2024
7a3d674
Correct comment
mhsmith Aug 13, 2024
f4b06f5
Merge branch 'main' into android-test-script
freakboy3742 Aug 13, 2024
47c02a5
Merge branch 'main' into android-test-script
mhsmith Aug 15, 2024
602fbdd
Add note about RAM requirements
mhsmith Aug 15, 2024
305d786
Handle transient failure of pidof
mhsmith Aug 15, 2024
2ad8e4b
Merge branch 'main' into android-test-script
freakboy3742 Aug 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Add android.py test command
  • Loading branch information
mhsmith committed Jul 10, 2024
commit b99b76942d6add61143180ecaf4bfa16e3bc73db
190 changes: 179 additions & 11 deletions Android/android.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,45 @@
#!/usr/bin/env python3

import argparse
import atexit
import os
import re
import shlex
import shutil
import subprocess
import sys
import sysconfig
from os.path import basename, relpath
from pathlib import Path
from subprocess import check_output
from tempfile import TemporaryDirectory
from threading import Thread
from time import sleep


SCRIPT_NAME = Path(__file__).name
CHECKOUT = Path(__file__).resolve().parent.parent
ANDROID_DIR = CHECKOUT / "Android"
TESTBED_DIR = ANDROID_DIR / "testbed"
CROSS_BUILD_DIR = CHECKOUT / "cross-build"


try:
android_home = os.environ['ANDROID_HOME']
except KeyError:
sys.exit("The ANDROID_HOME environment variable is required.")

adb = Path(
f"{android_home}/platform-tools/adb"
+ (".exe" if os.name == "nt" else "")
)
if not adb.exists():
sys.exit(
f"{adb} does not exist. Install the Platform Tools package using the "
f"Android SDK manager."
)


def delete_if_exists(path):
if path.exists():
print(f"Deleting {path} ...")
Expand All @@ -36,10 +60,13 @@ def subdir(name, *, clean=None):
return path


def run(command, *, host=None, **kwargs):
env = os.environ.copy()
def run(command, *, host=None, env=None, **kwargs):
if env is None:
env = os.environ.copy()
original_env = env.copy()

if host:
env_script = CHECKOUT / "Android/android-env.sh"
env_script = ANDROID_DIR / "android-env.sh"
env_output = subprocess.run(
Comment thread
freakboy3742 marked this conversation as resolved.
f"set -eu; "
f"HOST={host}; "
Expand All @@ -60,11 +87,10 @@ def run(command, *, host=None, **kwargs):
print(line)
env[key] = value

if env == os.environ:
if env == original_env:
raise ValueError(f"Found no variables in {env_script.name} output:\n"
+ env_output)

print(">", " ".join(map(str, command)))
try:
subprocess.run(command, check=True, env=env, **kwargs)
except subprocess.CalledProcessError as e:
Expand Down Expand Up @@ -173,12 +199,11 @@ def clean_all(context):
def setup_testbed(context):
ver_long = "8.7.0"
ver_short = ver_long.removesuffix(".0")
testbed_dir = CHECKOUT / "Android/testbed"

for filename in ["gradlew", "gradlew.bat"]:
out_path = download(
f"https://raw.githubusercontent.com/gradle/gradle/v{ver_long}/{filename}",
testbed_dir)
TESTBED_DIR)
os.chmod(out_path, 0o755)

with TemporaryDirectory(prefix=SCRIPT_NAME) as temp_dir:
Expand All @@ -187,10 +212,140 @@ def setup_testbed(context):
f"https://services.gradle.org/distributions/gradle-{ver_short}-bin.zip")
outer_jar = f"gradle-{ver_short}/lib/plugins/gradle-wrapper-{ver_short}.jar"
run(["unzip", bin_zip, outer_jar])
run(["unzip", "-o", "-d", f"{testbed_dir}/gradle/wrapper", outer_jar,
run(["unzip", "-o", "-d", f"{TESTBED_DIR}/gradle/wrapper", outer_jar,
"gradle-wrapper.jar"])


def list_devices():
serials = []
header_found = False

lines = check_output([adb, "devices"], text=True).splitlines()
for line in lines:
# Ignore blank lines, and all lines before the header.
line = line.strip()
if line == "List of devices attached":
header_found = True
elif header_found and line:
try:
serial, status = line.split()
except ValueError:
raise ValueError(f"failed to parse {line!r}")
if status == "device":
serials.append(serial)

if not header_found:
raise ValueError(f"failed to parse {lines}")
return serials


def wait_for_new_device(initial_devices):
while True:
new_devices = set(list_devices()).difference(initial_devices)
if len(new_devices) == 0:
sleep(1)
elif len(new_devices) == 1:
return new_devices.pop()
else:
sys.exit(f"Found more than one new device: {new_devices}")


def wait_for_uid():
while True:
Comment thread
freakboy3742 marked this conversation as resolved.
lines = check_output(
[adb, "shell", "pm", "list", "packages", "-U", "org.python.testbed"],
text=True
).splitlines()

if len(lines) == 0:
sleep(1)
elif len(lines) == 1:
if match := re.search(r"uid:\d+", lines[0]):
return match[1]
else:
raise ValueError(f"failed to parse {lines[0]!r}")
else:
sys.exit(f"Found more than one UID: {lines}")


def logcat_thread(context, initial_devices):
serial = context.connected or wait_for_new_device(initial_devices)

# Because Gradle uninstalls the app after running the tests, its UID should
# be different every time. There's therefore no need to filter the logs by
# timestamp or PID.
uid = wait_for_uid()

logcat = subprocess.Popen(
[adb, "-s", serial, "logcat", "--uid", uid, "--format", "tag"],
stdout=subprocess.PIPE, text=True
)

# This is a daemon thread, so `finally` won't work.
atexit.register(logcat.kill)

for line in logcat.stdout:
if match := re.fullmatch(r"(\w)/(\w+): (.*)", line):
level, tag, message = match.groups()
else:
# If the regex doesn't match, this is probably the second or
# subsequent line of a multi-line message. Python won't produce
# such messages, but other components might.
level, tag, message = None, None, line

stream = (
sys.stderr
if level in ["E", "F"] # ERROR and FATAL (aka ASSERT)
else sys.stdout
)

# We strip the level/tag indicator from Python's stdout and stderr, to
# simplify automated processing of the output, e.g. a buildbot posting a
# failure notice on a GitHub PR.
#
# Non-Python messages from the app are still worth keeping, as they may
# help explain any problems.
stream.write(
message if tag in ["python.stdout", "python.stderr"] else line
)

status = logcat.wait()
if status != 0:
sys.exit(f"Logcat exit status {status}")


def run_testbed(context):
if not (TESTBED_DIR / "gradlew").exists():
setup_testbed(context)

kwargs = dict(cwd=TESTBED_DIR)

if context.connected:
task_prefix = "connected"
env = os.environ.copy()
env["ANDROID_SERIAL"] = context.connected
kwargs.update(env=env)
elif context.managed:
task_prefix = context.managed
else:
raise ValueError("no device argument found")

Thread(
target=logcat_thread, args=(context, list_devices()), daemon=True
).start()

run(
[
"./gradlew",
"--console", "plain",
f"{task_prefix}DebugAndroidTest",
"-Pandroid.testInstrumentationRunnerArguments.pythonArgs="
+ shlex.join(context.args),
],
**kwargs
)


def main():
parser = argparse.ArgumentParser()
subcommands = parser.add_subparsers(dest="subcommand")
Expand All @@ -206,8 +361,6 @@ def main():
help="Run `make` for Android")
subcommands.add_parser(
"clean", help="Delete the cross-build directory")
subcommands.add_parser(
"setup-testbed", help="Download the testbed Gradle wrapper")

for subcommand in build, configure_build, configure_host:
subcommand.add_argument(
Expand All @@ -222,14 +375,29 @@ def main():
subcommand.add_argument("args", nargs="*",
help="Extra arguments to pass to `configure`")

subcommands.add_parser(
"setup-testbed", help="Download the testbed Gradle wrapper")
test = subcommands.add_parser(
"test", help="Run the test suite")
device_group = test.add_mutually_exclusive_group(required=True)
device_group.add_argument(
"--connected", metavar="SERIAL", help="Run on a connected device. "
"Connect it yourself, then get its serial from `adb devices`.")
device_group.add_argument(
"--managed", metavar="NAME", help="Run on a Gradle-managed device. "
"These are defined in `managedDevices` in testbed/app/build.gradle.kts.")
test.add_argument(
"args", nargs="*", help="Extra arguments for `python -m test`")

context = parser.parse_args()
dispatch = {"configure-build": configure_build_python,
"make-build": make_build_python,
"configure-host": configure_host_python,
"make-host": make_host_python,
"build": build_all,
"clean": clean_all,
"setup-testbed": setup_testbed}
"setup-testbed": setup_testbed,
"test": run_testbed}
dispatch[context.subcommand](context)


Expand Down
14 changes: 14 additions & 0 deletions Android/testbed/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ android {
kotlinOptions {
jvmTarget = "1.8"
}

testOptions {
managedDevices {
localDevices {
// In the future we may add a "minSdk" device, but managed
// devices have a minimum API level of 27.
create("targetSdk") {
device = "Small Phone"
apiLevel = defaultConfig.targetSdk!!
systemImageSource = "aosp-atd"
}
}
}
}
}

dependencies {
Expand Down