blob: 883c4ffce4a9cc995ac4ed4b042cfafa325d5fc5 [file] [log] [blame] [edit]
#!/usr/bin/python
import argparse
import atexit
import glob
import http.server
import os
import platform
import queue
import re
import shutil
import signal
import socket
import socketserver
import subprocess
import sys
import threading
import time
import urllib.request
import zipfile
HANDSHAKE_TOKEN = 0xfee1600d
SHUTDOWN_TOKEN = 0xfee1dead
parser = argparse.ArgumentParser(description="Run native gms & goldens, and dump their .pngs")
parser.add_argument("tools",
type=str,
nargs="+",
choices=["gms", "goldens", "player"],
help="which tool(s) to run")
parser.add_argument("-B", "--builddir",
type=str,
default=None,
help="output directory from build")
parser.add_argument("-b", "--backend",
type=str,
default=None)
parser.add_argument("-s", "--src",
type=str,
default=os.path.join("..", "..", "..", "zzzgold", "rivs"),
help="INPUT directory of .riv files to render")
parser.add_argument("-o", "--outdir",
type=str,
default=os.path.join(".gold", "candidates"),
help="base directory to output the PNG directory structure")
parser.add_argument("-p", "--png_threads",
type=int,
default=4,
help="Number of pngs encoding threads on each tool process.")
parser.add_argument("-j", "--jobs-per-tool",
type=int,
default=4,
help="number of processes to spawn for each tool in 'args.tools' "\
"(non-mobile only; android/ios only get one job)")
parser.add_argument("--rows",
type=int,
default=1,
help="number of rows in the goldens grid")
parser.add_argument("--cols",
type=int,
default=1,
help="number of columns in the goldens grid")
parser.add_argument("-m", "--match",
type=str,
default=None,
help="`match` patter for gms")
parser.add_argument("-t", "--target",
default="host",
choices=["host", "android", "ios", "iossim", "unreal",
"unreal_android", "webbrowser", "webserver"],
help="which platform to run on")
parser.add_argument("-a", "--android-arch",
default="arm64",
choices=["arm", "arm64"])
parser.add_argument("-u", "--ios_udid",
type=str,
default=None,
help="unique id of iOS device to run on (--target=ios or iossim)")
parser.add_argument("-c", "--webclient",
default=None,
help="executable to launch when --target=webserver")
parser.add_argument("-k", "--options",
type=str,
default=None,
help="additional options to pass through (player only)")
parser.add_argument("-S", "--server_only",
action='store_true',
help="Start servers but don't launch gms or goldens tools")
parser.add_argument("-r", "--remote",
action='store_true',
help="target is remote; serve from host IP instead of localhost")
parser.add_argument("--build-only", action='store_true',
help="only build, don't deploy")
parser.add_argument("--no-rebuild", action='store_true',
help="don't rebuild the native tools in builddir")
parser.add_argument("-n", "--no-install", action='store_true',
help="don't package & reinstall the mobile app prior to launch")
parser.add_argument("-v", "--verbose", action='store_true', help="enable verbose output")
args = parser.parse_args()
skipped_golden_tests = set()
target_info = {} # dictionary for info about the target (ios_version, etc.)
rivsqueue = queue.Queue()
# Global pump on stdin.
input_lock = None
input_received_cond = None
input_chars = bytearray()
input_cancelled = False
def pump_input_thread():
global input_chars
while not input_cancelled:
try:
chars = sys.stdin.readline().encode("ascii")
with input_lock:
input_chars += chars
input_received_cond.notify()
except EOFError:
return
def get_input():
global input_lock, input_received_cond, input_chars
if not input_lock:
input_lock = threading.Lock()
input_received_cond = threading.Condition(input_lock)
threading.Thread(target=pump_input_thread, daemon=True).start()
with input_lock:
while not input_cancelled and len(input_chars) < 1:
input_received_cond.wait()
chars = input_chars
input_chars = bytearray()
return chars
def cancel_input():
global input_cancelled
with input_lock:
input_cancelled = True
input_received_cond.notify_all()
class text_colors:
ERROR = '\033[91m'
ENDCOL = '\033[0m'
# Launch a process in a separate thread and crash if it fails.
class CheckProcess(threading.Thread):
def __init__(self, cmd):
threading.Thread.__init__(self)
self.cmd = cmd
if args.server_only:
self.cmd = ["echo", "\n <command> "] + ['"%s"' % arg for arg in self.cmd]
if args.verbose:
print(' '.join(self.cmd), flush=True)
if shutil.which(self.cmd[0]) is None:
print(f'{text_colors.ERROR}' + self.cmd[0] + ' does not exist!' + f'{text_colors.ENDCOL}')
else:
self.proc = subprocess.Popen(self.cmd)
def run(self):
self.proc.wait()
if self.proc.returncode != 0:
os._exit(self.proc.returncode)
def terminate(self):
self.proc.terminate()
def get_local_ip():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(0)
try:
# doesn't even have to be reachable
s.connect(("10.254.254.254", 1))
ip = s.getsockname()[0]
except Exception:
ip = "127.0.0.1"
finally:
s.close()
return ip
# Simple http server for web-based targets.
def start_http_server(directory, server_ip):
import functools
import http.server
http_port_holder = []
http_port_ready_event = threading.Event()
class COOPHandler(http.server.SimpleHTTPRequestHandler):
def end_headers(self):
# Serve cross-origin isolated pages so SharedArrayBuffer is defined
# for emscripten POSIX emulation.
self.send_header("Cross-Origin-Opener-Policy", "same-origin")
self.send_header("Cross-Origin-Embedder-Policy", "require-corp")
super().end_headers()
def log_message(self, format, *args):
# Suppress default HTTP logs like:
# 127.0.0.1 - - [22/Aug/2025 13:37:00] "GET /index.html HTTP/1.1" 200 -
pass
handler = functools.partial(COOPHandler, directory=directory)
def run_http_server():
with socketserver.TCPServer((server_ip, 0), handler) as httpd:
http_port_holder.append(httpd.server_address[1])
http_port_ready_event.set()
httpd.serve_forever()
thread = threading.Thread(target=run_http_server, daemon=True)
thread.start()
http_port_ready_event.wait() # wait until server is ready
return (server_ip, http_port_holder[0])
# Simple websocket <-> TCP bridge for web-based targets.
def start_websocket_bridge(tcp_server_address):
import asyncio
import threading
import socket
import websockets
websocket_port_holder = []
port_ready_event = threading.Event()
async def handle_websocket(websocket):
reader, writer = await asyncio.open_connection(*tcp_server_address)
async def tcp_to_ws():
try:
while True:
data = await reader.read(1024)
if not data:
break
await websocket.send(data)
except:
raise
async def ws_to_tcp():
try:
async for message in websocket:
if isinstance(message, str):
message = message.encode()
writer.write(message)
await writer.drain()
except:
raise
await asyncio.gather(tcp_to_ws(), ws_to_tcp())
writer.close()
await writer.wait_closed()
async def run_server(sock):
async with websockets.serve(handle_websocket, sock=sock):
await asyncio.Future() # run forever
def server_thread():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(('0.0.0.0', 0)) # OS assigns port
sock.listen(5)
sock.setblocking(False)
websocket_port_holder.append(sock.getsockname()[1])
port_ready_event.set()
asyncio.run(run_server(sock))
thread = threading.Thread(target=server_thread, daemon=True)
thread.start()
port_ready_event.wait()
return (tcp_server_address[0], websocket_port_holder[0])
# Simple TCP server for Rive tools.
class ToolServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
daemon_threads = True
def __init__(self, handler):
if args.remote:
# The device needs to connect over the network instead of localhost.
self.host = get_local_ip()
else:
self.host = "localhost" # Desktop and Android (with "adb reverse") can use local ports.
address = (self.host, 0) # let the kernel give us a port
self.shutdown_event = None
self.claimed_gm_tests_lock = threading.Lock()
# "Claim" the skipped tests upfront so they don't get drawn.
self.claimed_gm_tests = set(skipped_golden_tests)
super().__init__(address, handler)
def server_activate(self):
# Allow enough connections for each process we spawn.
num_processes = len(args.tools) * args.jobs_per_tool
# Each process establishes a maximum of:
# * 1 primary TestHarness server connection.
# * `png_threads` encoder connections.
# * 1 stdio-forwarding connection.
# * 1 input pump connection.
threads_per_process = 1 + args.png_threads + 1 + 1
self.socket.listen(num_processes * threads_per_process)
def serve_forever_async(self):
self.serve_thread = threading.Thread(target=self.serve_forever, daemon=True)
self.serve_thread.start()
if self.host == "localhost" and args.target == "android":
# Use adb port reverse-forwarding to expose our RIV server to the device.
hostname, port = self.server_address # find out what port we were given
subprocess.Popen(["adb", "reverse", "tcp:%s" % port, "tcp:%s" % port],
stdout=subprocess.DEVNULL)
print("TestHarness server running on %s:%u" % self.server_address, flush=True)
if args.target.startswith("web"):
self.server_address = start_websocket_bridge(self.server_address)
print("TestHarness server bridged to WebSocket on %s:%u" % self.server_address,
flush=True)
self.http_address = start_http_server(args.builddir,
self.server_address[0])
print("HTTP server running from %s on %s:%u" % (args.builddir,
*self.http_address),
flush=True)
# Simple utility to wait until a TCP client tells the server it has finished.
def wait_for_shutdown_event(self, timeout=threading.TIMEOUT_MAX):
if not self.shutdown_event:
return True
if timeout == threading.TIMEOUT_MAX:
while not self.shutdown_event.wait(timeout=1):
# Poll every second or Python doesn't receive ^C on windows.
pass
elif not self.shutdown_event.wait(timeout=timeout):
return False
self.shutdown_event = None
return True
def reset_shutdown_event(self):
self.shutdown_event = threading.Event()
def synthesize_shutdown_event(self):
if self.shutdown_event:
self.shutdown_event.set()
# Only returns True the on the first request for a given name.
# Prevents gms from running more than once in a multi-process execution.
def claim_gm_test(self, name):
with self.claimed_gm_tests_lock:
if not name in self.claimed_gm_tests:
self.claimed_gm_tests.add(name)
return True
return False
# RequestHandler with services for Rive tools.
class TestHarnessRequestHandler(socketserver.BaseRequestHandler):
REQUEST_TYPE_IMAGE_UPLOAD = 0
REQUEST_TYPE_CLAIM_GM_TEST = 1
REQUEST_TYPE_FETCH_RIV_FILE = 2
REQUEST_TYPE_GET_INPUT = 3
REQUEST_TYPE_CANCEL_INPUT = 4
REQUEST_TYPE_PRINT_MESSAGE = 5
REQUEST_TYPE_DISCONNECT = 6
REQUEST_TYPE_APPLICATION_CRASH = 7
def recvall(self, byte_count):
data = bytearray()
while len(data) < byte_count:
chunk = self.request.recv(min(byte_count - len(data), 4096))
data.extend(chunk)
return data
def recv_string(self):
length = int.from_bytes(self.recvall(4), byteorder="big")
return self.recvall(length).decode("ascii")
def handle(self):
try:
while True:
# Receive the next request.
requesttype = int.from_bytes(self.recvall(4), byteorder="big")
if requesttype == self.REQUEST_TYPE_DISCONNECT:
shutdown = int.from_bytes(self.recvall(4), byteorder="big")
if self.server and shutdown:
if self.server.shutdown_event:
self.server.shutdown_event.set()
break
elif requesttype == TestHarnessRequestHandler.REQUEST_TYPE_IMAGE_UPLOAD:
destination = os.path.join(args.outdir, self.recv_string())
with open(destination, "wb") as pngfile:
while True:
chunksize = int.from_bytes(self.recvall(4), byteorder="big")
if chunksize == HANDSHAKE_TOKEN:
break
pngfile.write(self.recvall(chunksize))
self.request.sendall(HANDSHAKE_TOKEN.to_bytes(4, byteorder="big"))
if args.verbose:
print("[server] Received %s" % destination, flush=True)
elif requesttype == TestHarnessRequestHandler.REQUEST_TYPE_CLAIM_GM_TEST:
shouldrun = self.server.claim_gm_test(self.recv_string())
self.request.sendall(shouldrun.to_bytes(4, byteorder="big"))
elif requesttype == TestHarnessRequestHandler.REQUEST_TYPE_FETCH_RIV_FILE:
remaining = rivsqueue.qsize()
if not args.verbose and remaining % 7 == 0:
print("[%3u] rivs remaining...\r" % remaining,
end='\r' if not args.verbose else '', flush=True)
try:
while True:
riv = rivsqueue.get_nowait()
riv_basename = os.path.basename(riv)
if not riv_basename in skipped_golden_tests:
break
if args.verbose:
print("[server] Sending %s..." % riv, end='\r', flush=True)
filename_ascii = riv_basename.encode("ascii")
self.request.sendall(len(filename_ascii).to_bytes(4, byteorder="big"))
self.request.sendall(filename_ascii)
with open(riv, "rb") as rivfile:
rivbytes = rivfile.read()
self.request.sendall(len(rivbytes).to_bytes(4, byteorder="big"))
self.request.sendall(rivbytes)
except queue.Empty:
# .rivs are finished. Tell the client to shutdown.
self.request.sendall(SHUTDOWN_TOKEN.to_bytes(4, byteorder="big"))
elif requesttype == TestHarnessRequestHandler.REQUEST_TYPE_GET_INPUT:
chars = get_input()
if input_cancelled:
if args.verbose:
print("[server] shutting down input pump", flush=True)
self.request.sendall(SHUTDOWN_TOKEN.to_bytes(4, byteorder="big"))
else:
if args.verbose:
print("[server] sending ", chars, flush=True)
self.request.sendall(len(chars).to_bytes(4, byteorder="big"))
self.request.sendall(chars)
elif requesttype == TestHarnessRequestHandler.REQUEST_TYPE_CANCEL_INPUT:
cancel_input()
elif requesttype == TestHarnessRequestHandler.REQUEST_TYPE_PRINT_MESSAGE:
print(self.recv_string(), end="", flush=True)
elif requesttype == TestHarnessRequestHandler.REQUEST_TYPE_APPLICATION_CRASH:
print("CRASH in tool: %s" % self.recv_string(), flush=True)
os._exit(-1)
except ConnectionResetError:
print("TestHarness connection reset by client tool", flush=True)
os._exit(-1)
# If we aren't deploying to the host, update the given command to deploy on its intended target.
def update_cmd_to_deploy_on_target(cmd, test_harness_server):
dirname = os.path.dirname(cmd[0])
toolname = os.path.basename(cmd[0])
if args.target == "unreal":
unreal_exe_path = os.path.join(dirname, "Windows", "rive_unreal.exe")
return [unreal_exe_path, "/Game/maps/" + toolname, "-ResX=1280", "-ResY=720", "-WINDOWED"] + cmd[1:]
if args.target == "unreal_android":
tool_args = ' '.join(["/Game/maps/" + toolname] + cmd[1:])
return ["adb", "shell",
"am force-stop app.rive.rive_unreal && "
f"am start -n app.rive.rive_unreal/com.epicgames.unreal.GameActivity -e args '{tool_args}'"]
if args.target == "android":
sharedlib = os.path.join(dirname, "lib%s.so" % toolname)
print("\nDeploying %s on android..." % sharedlib)
tool_args = ' '.join([sharedlib] + cmd[1:])
return ["adb", "shell",
"am force-stop app.rive.android_tests && "
"am start -n app.rive.android_tests/.%s -e args '%s'" % (toolname, tool_args)]
elif args.target == "ios":
print("\nDeploying %s on ios (udid=%s, ios_version=%i)..." %
(toolname, args.ios_udid, target_info["ios_version"]))
cmd = [toolname] + cmd[1:]
if target_info["ios_version"] >= 17:
# ios-deploy is no longer supported after iOS 17.
return ["xcrun", "devicectl", "device", "process", "launch",
# "--console", # TODO: "--console" not currently supported.
"--device", args.ios_udid,
"--environment-variables", '{"MTL_DEBUG_LAYER": "1"}',
"rive.app.golden-test-app"] + cmd
else:
return ["ios-deploy", "--noinstall", "--noninteractive", "--bundle",
"ios_tests/build/Debug-iphoneos/rive_ios_tests.app",
"--envs", "MTL_DEBUG_LAYER=1",
"--args", ' '.join(cmd)]
elif args.target == "iossim":
print("\nDeploying %s on ios simulator (udid=%s)..." % (toolname, args.ios_udid))
cmd = [toolname] + cmd[1:]
return ["xcrun", "simctl", "launch", args.ios_udid, "rive.app.golden-test-app"] + cmd
elif args.target.startswith("web"):
if args.target == "webbrowser":
client = ["python3", "-m", "webbrowser", "-t"]
elif args.webclient:
client = [args.webclient]
else:
client = ["echo", "\nPlease navigate your web client to:\n\n"]
return client + ["http://%s:%u/%s.html#%s" % (*test_harness_server.http_address,
toolname,
'%20'.join(cmd[1:]))]
else:
assert(args.target == "host")
return cmd
def launch_gms(test_harness_server):
tool = os.path.join(args.builddir, "gms")
if platform.system() == "Windows" and args.target == 'host':
tool = tool + ".exe"
cmd = [tool,
"--backend", args.backend,
"--test_harness", "%s:%u" % test_harness_server.server_address]
if not args.target.startswith("web"):
cmd = cmd + ["--headless",
"-p%i" % args.png_threads];
if args.match:
cmd = cmd + ["--match", args.match];
if args.verbose:
cmd = cmd + ["--verbose"];
cmd = update_cmd_to_deploy_on_target(cmd, test_harness_server)
procs = [CheckProcess(cmd) for i in range(0, args.jobs_per_tool)]
for proc in procs:
proc.start()
return procs
def launch_goldens(test_harness_server):
tool = os.path.join(args.builddir, "goldens")
if platform.system() == "Windows" and args.target == 'host':
tool = tool + ".exe"
if args.verbose:
print("[server] Using '" + tool + "'", flush=True)
if not os.path.exists(args.src):
print("Can't find rivspath " + args.src, flush=True)
return -1;
if os.path.isdir(args.src):
for riv in glob.iglob(os.path.join(args.src, "*.riv")):
rivsqueue.put(riv)
else:
rivsqueue.put(args.src)
cmd = [tool,
"--test_harness", "%s:%u" % test_harness_server.server_address,
"--backend", args.backend,
"--rows", str(args.rows),
"--cols", str(args.cols)]
if not args.target.startswith("web"):
cmd = cmd + ["--headless",
"-p%i" % args.png_threads];
if args.verbose:
cmd = cmd + ["--verbose"];
cmd = update_cmd_to_deploy_on_target(cmd, test_harness_server)
procs = [CheckProcess(cmd) for i in range(0, args.jobs_per_tool)]
for proc in procs:
proc.start()
return procs
def launch_player(test_harness_server):
if not os.path.exists(args.src):
print("Can't find riv path " + args.src, flush=True)
return -1;
tool = os.path.join(args.builddir, "player")
if platform.system() == "Windows" and args.target == 'host':
tool = tool + ".exe"
cmd = [tool,
"--test_harness", "%s:%u" % test_harness_server.server_address,
"--backend", args.backend]
if args.options:
cmd += ["--options", args.options]
cmd = update_cmd_to_deploy_on_target(cmd, test_harness_server)
rivsqueue.put(args.src)
player = CheckProcess(cmd)
player.start()
return player
def force_stop_android_tests_apk():
subprocess.check_call(["adb", "shell", "am force-stop app.rive.android_tests"])
def main():
# Parse skipped tests.
rive_skipped_golden_tests = os.getenv("RIVE_SKIPPED_GOLDEN_TESTS")
if rive_skipped_golden_tests:
for test in rive_skipped_golden_tests.split(","):
if '/' in test:
# A "/" character separates a specific backend from a test name.
# Only skip one of these tests if it's the same backend we're currently running.
backend, test = test.split("/", 1)
if backend != args.backend:
continue
skipped_golden_tests.add(test)
if args.verbose:
print("[server] skipped_golden_tests: ", skipped_golden_tests, flush=True)
if args.target == "android":
args.jobs_per_tool = 1 # Android can only launch one process at a time.
if args.builddir == None:
args.builddir = f"out/android_{args.android_arch}_debug"
if args.backend == None:
args.backend = "gl"
elif args.target == "ios":
if args.builddir == None:
args.builddir = "out/ios_debug"
elif args.builddir != "out/ios_debug":
print("The iOS wrapper app requires --builddir=out/ios_debug")
return -1
if args.backend == None:
args.backend = "metal"
args.jobs_per_tool = 1 # iOS can only launch one process at a time.
args.remote = True # Since we can't do port forwarding in iOS, it always has to be remote.
if not args.ios_udid:
args.ios_udid = subprocess.check_output(["idevice_id", "-l"]).decode().strip()
device_info = os.popen("xcrun xctrace list devices | grep %s" % args.ios_udid).read()
target_info["ios_version"] = int(re.search(r".+\(([0-9]+)\.[0-9\.]+\).+$",
device_info).group(1))
elif args.target == "iossim":
if args.builddir == None:
args.builddir = "out/iossim_universal_debug"
elif args.builddir != "out/iossim_universal_debug":
print("The iOS-simulator wrapper app requires --builddir=out/iossim_universal_debug")
return -1
if args.backend == None:
args.backend = "metal"
args.jobs_per_tool = 1 # iOS can only launch one process at a time.
args.remote = True # Since we can't do port forwarding in iOS, it always has to be remote.
if not args.ios_udid:
args.ios_udid = "booted"
elif args.target == 'unreal' or args.target == 'unreal_android':
# currently, unreal needs to run only one job at a time for goldens and gms to work
args.jobs_per_tool = 1
if args.builddir == None:
args.builddir = os.path.join("out", "debug")
# unreal is currently always rhi, we may have seperate rhi types in the future like rhi_metal etc..
args.backend = 'rhi'
elif args.target.startswith("web"):
args.jobs_per_tool = 1
if args.builddir == None:
args.builddir = f"out/wasm_debug"
if args.backend == None:
args.backend = "gl"
else:
assert(args.target == "host")
if args.builddir == None:
args.builddir = os.path.join("out", "debug")
if args.backend == None:
args.backend = "metal" if platform.system() == "Darwin" else \
"d3d" if platform.system() == "Windows" else \
"gl"
if "metal" in args.backend:
# Turn on Metal validation layers.
# NOTE: MoltenVK generates Metal validation errors right now, so only them on for our own
# Metal backends.
os.environ["MTL_DEBUG_LAYER"] = "1"
if args.server_only:
args.jobs_per_tool = 1 # Only print the command for each job once.
rive_tools_dir = os.path.dirname(os.path.realpath(__file__))
if "ios" in args.target:
# ios links statically, so we need to build every tool every time.
build_targets = ["gms", "goldens", "player"]
else:
build_targets = args.tools
# Build the native code.
if not args.no_rebuild and not args.no_install:
build_rive = [os.path.join(rive_tools_dir, "../build/build_rive.sh")]
if os.name == "nt":
if subprocess.run(["which", "msbuild.exe"]).returncode == 0:
# msbuild.exe is already on the $PATH; launch build_rive.sh directly.
build_rive = ["sh"] + build_rive
else:
# msbuild.exe is not on the path; go through build_rive.bat.
build_rive[0] = os.path.splitext(build_rive[0])[0] + '.bat'
subprocess.check_call(build_rive + ["rebuild", args.builddir] + build_targets)
# Build the wrapper app, if applicable
if not args.no_install:
if args.target == "unreal_android":
unreal_android_path = os.path.join(args.builddir, "Android_ASTC")
current = os.getcwd()
os.chdir(unreal_android_path);
subprocess.check_call(["Install_rive_unreal-arm64.bat"])
print()
os.chdir(current);
if args.target == "android":
# Copy the native libraries into the android_tests project.
jnidir = os.path.join("android_tests", "app", "src", "main", "jniLibs")
shutil.rmtree(jnidir, ignore_errors=True)
if args.android_arch == "arm64":
arch_full_name = "arm64-v8a"
else:
assert(args.android_arch == "arm")
arch_full_name = "armeabi-v7a"
os.makedirs(os.path.join(jnidir, arch_full_name))
for tool in build_targets:
sharedlib = "lib%s.so" % tool
shutil.copy(os.path.join(args.builddir, sharedlib),
os.path.join(jnidir, arch_full_name))
if "vk" in args.backend or "vulkan" in args.backend:
layerpath = os.path.join("dependencies", "Vulkan-ValidationLayers")
if not os.path.exists(layerpath):
# Download the Vulkan validation layers.
print("Downloading Android Vulkan validation layers...", flush=True)
url = "https://github.com/KhronosGroup/Vulkan-ValidationLayers/releases/download/"\
"vulkan-sdk-1.3.290.0/android-binaries-1.3.290.0.zip"
zipfile.ZipFile(urllib.request.urlretrieve(url)[0], 'r').extractall(path=layerpath)
# Bundle the Vulkan validation layers.
for lib in glob.glob(os.path.join(layerpath,
"android-binaries-1.3.290.0",
arch_full_name,
"*.so")):
dst = os.path.join(jnidir, arch_full_name, os.path.basename(lib))
print(" bundling %s -> %s" % (lib, dst), flush=True)
shutil.copy(lib, dst)
# Build the android_tests wrapper app.
cwd = os.getcwd()
os.chdir(os.path.join(rive_tools_dir, "android_tests"))
# Check for Java availability
if (os.getenv("JAVA_HOME") is None and
subprocess.run(["java", "-version"], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL).returncode != 0):
print("Error: Java is required to run Android Gradle tests. Please ensure the $JAVA_HOME environment variable is set. You may use Android Studio's JDK (the path can be found at Settings > Build, Execution, Deployment > Build Tools > Gradle > Gradle JDK).")
return -1
# Check for $ANDROID_HOME
if not os.getenv("ANDROID_HOME"):
print("Error: $ANDROID_HOME is not set. Please set it to the path of your Android SDK. You may use Android Studio's SDK (the path can be found at Settings > Languages & Frameworks > Android SDK > Android SDK Location).")
return -1
# Call gradlew to build the android_tests wrapper app.
subprocess.check_call(["./gradlew" if os.name != "nt" else "gradlew.bat",
":app:assembleDebug"])
os.chdir(cwd)
elif args.target == "ios":
# Build the ios_tests wrapper app.
subprocess.check_call(["xcodebuild",
"-destination", "generic/platform=iOS",
"-config", "Debug",
"build", "-project", "ios_tests/ios_tests.xcodeproj"])
elif args.target == "iossim":
# Build the ios_tests wrapper app for the simulator.
subprocess.check_call(["xcodebuild",
"-destination", "generic/platform=iOS Simulator",
"-config", "Debug",
"-sdk", "iphonesimulator",
"build", "-project", "ios_tests/ios_tests.xcodeproj"])
if args.build_only:
return 0
# Install the wrapper app, if applicable.
if not args.no_install:
if args.target == "android":
# Install the android_tests wrapper app.
force_stop_android_tests_apk()
subprocess.check_call(["adb", "install", "-r", "android_tests/app/build/outputs/apk/debug/app-debug.apk"])
print()
elif args.target == "ios":
# Install the ios_tests wrapper app on the device.
if target_info["ios_version"] >= 17:
# ios-deploy is no longer supported after iOS 17.
subprocess.check_call(["xcrun", "devicectl", "device", "install", "app", "--device",
args.ios_udid,
"ios_tests/build/Debug-iphoneos/rive_ios_tests.app"])
else:
subprocess.check_call(["ios-deploy", "--bundle",
"ios_tests/build/Debug-iphoneos/rive_ios_tests.app"])
print()
elif args.target == "iossim":
# Install the ios_tests wrapper app on the simulator.
subprocess.check_call(["xcrun", "simctl", "install", args.ios_udid,
"ios_tests/build/Debug-iphonesimulator/rive_ios_tests.app"])
print()
if args.target == "android":
atexit.register(force_stop_android_tests_apk)
with (ToolServer(TestHarnessRequestHandler) as test_harness_server):
test_harness_server.serve_forever_async()
# On many targets we can't launch >1 instance of the app at a time.
serial_deploy = not args.server_only and ("ios" in args.target or
args.target == "android" or
"unreal" in args.target or
args.target.startswith("web"))
procs = []
def keyboard_interrupt_handler(signal, frame):
if os.name == "nt":
print("^C", end="", flush=True)
cancel_input()
for proc in procs:
proc.terminate()
if not test_harness_server.wait_for_shutdown_event(1.5):
test_harness_server.synthesize_shutdown_event()
signal.signal(signal.SIGINT, keyboard_interrupt_handler)
if "gms" in args.tools:
os.makedirs(args.outdir, exist_ok=True)
if serial_deploy:
test_harness_server.reset_shutdown_event()
procs = launch_gms(test_harness_server)
assert(len(procs) == 1)
test_harness_server.wait_for_shutdown_event()
procs[0].join()
procs = []
if args.target == "android":
force_stop_android_tests_apk()
time.sleep(3)
else:
procs += launch_gms(test_harness_server)
if "goldens" in args.tools:
os.makedirs(args.outdir, exist_ok=True)
if serial_deploy:
test_harness_server.reset_shutdown_event()
procs = launch_goldens(test_harness_server)
assert(len(procs) == 1)
test_harness_server.wait_for_shutdown_event()
procs[0].join()
procs = []
if args.target == "android":
force_stop_android_tests_apk()
time.sleep(3)
else:
procs += launch_goldens(test_harness_server)
if "player" in args.tools:
test_harness_server.reset_shutdown_event()
procs = [launch_player(test_harness_server)]
test_harness_server.wait_for_shutdown_event()
procs[0].join()
procs = []
# Wait for the processes to finish (if not in serial_deploy mode).
for proc in procs:
proc.join()
if args.server_only:
# Sleep until user input.
input("\nPress enter to shutdown...")
print("done ", flush=True)
test_harness_server.shutdown()
return 0
if __name__ == "__main__":
sys.exit(main())