Add super trivial smoke test

This commit is contained in:
Nathan Adams 2024-03-26 02:29:57 +01:00
parent 77a545319d
commit e9ebf59a54
12 changed files with 353 additions and 104 deletions

View File

@ -82,6 +82,9 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
- name: Test
run: ./gradlew deviceTests
- name: Decode keystore
if: ${{ !github.event.pull_request.head.repo.fork }}
env:

View File

@ -1,3 +1,5 @@
@file:Suppress("UnstableApiUsage")
import com.github.willir.rust.CargoNdkBuildTask
plugins {
@ -91,6 +93,18 @@ android {
isUniversalApk = true
}
}
testOptions {
managedDevices {
localDevices {
create("pixel3api34") {
device = "Pixel 3"
apiLevel = 34
systemImageSource = "aosp"
}
}
}
}
}
dependencies {
@ -109,6 +123,9 @@ dependencies {
implementation(libs.androidx.games.activity)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.appcompat)
androidTestImplementation(libs.androidx.uiautomator)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.rules)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
@ -131,3 +148,9 @@ cargoNdk {
apiLevel = 26
buildType = "release"
}
tasks {
register("deviceTests") {
dependsOn("pixel3api34DebugAndroidTest")
}
}

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:versionCode="1"
android:versionName="1.0">
<uses-sdk android:minSdkVersion="18" android:targetSdkVersion="28" />
<instrumentation android:targetPackage="rs.ruffle"
android:name="androidx.test.runner.AndroidJUnitRunner"/>
<application tools:replace="label" android:label="SmokeTest" />
</manifest>

View File

@ -1,22 +0,0 @@
package rs.ruffle
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("rs.ruffle", appContext.packageName)
}
}

View File

@ -0,0 +1,83 @@
package rs.ruffle
import android.R
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.matcher.ViewMatchers.assertThat
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until
import java.io.File
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.CoreMatchers.notNullValue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
private const val BASIC_SAMPLE_PACKAGE = "rs.ruffle"
private const val LAUNCH_TIMEOUT = 5000L
@RunWith(AndroidJUnit4::class)
class SmokeTest {
private lateinit var device: UiDevice
private lateinit var traceOutput: File
private lateinit var swfFile: File
@Before
fun startMainActivityFromHomeScreen() {
// Initialize UiDevice instance
device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
// Start from the home screen
device.pressHome()
// Wait for launcher
val launcherPackage: String = device.launcherPackageName
assertThat(launcherPackage, notNullValue())
device.wait(
Until.hasObject(By.pkg(launcherPackage).depth(0)),
LAUNCH_TIMEOUT
)
// Launch the app
val context = ApplicationProvider.getApplicationContext<Context>()
traceOutput = File.createTempFile("trace", ".txt", context.cacheDir)
swfFile = File.createTempFile("movie", ".swf", context.cacheDir)
val resources = InstrumentationRegistry.getInstrumentation().context.resources
val inStream = resources.openRawResource(
rs.ruffle.test.R.raw.helloflash
)
val bytes = inStream.readBytes()
swfFile.writeBytes(bytes)
val intent = context.packageManager.getLaunchIntentForPackage(
BASIC_SAMPLE_PACKAGE
)?.apply {
component = ComponentName("rs.ruffle", "rs.ruffle.PlayerActivity")
data = Uri.fromFile(swfFile)
putExtra("traceOutput", traceOutput.absolutePath)
// Clear out any previous instances
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
}
context.startActivity(intent)
// Wait for the app to appear
device.wait(
Until.hasObject(By.pkg(BASIC_SAMPLE_PACKAGE).depth(0)),
LAUNCH_TIMEOUT
)
}
@Test
fun emulatorRunsASwf() {
device.waitForWindowUpdate(null, 1000)
assertThat(device, notNullValue())
val trace = traceOutput.readLines()
assertThat(trace, equalTo(listOf("Hello from Flash!")))
}
}

Binary file not shown.

View File

@ -55,6 +55,13 @@ class PlayerActivity : GameActivity() {
return intent.dataString
}
@Suppress("unused")
// Used by Rust
private val traceOutput: String?
get() {
return intent.getStringExtra("traceOutput")
}
@Suppress("unused")
// Used by Rust
private fun navigateToUrl(url: String?) {

View File

@ -15,6 +15,9 @@ gamesActivity = "2.0.2" # Needs to be in sync with android-activity crate
constraintlayout = "2.1.4"
appcompat = "1.6.1"
ktlint = "12.1.0"
uiautomator = "2.3.0"
androidXTestRunner = "1.5.2"
androidXTestRules = "1.5.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -37,6 +40,9 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio
androidx-games-activity = { group = "androidx.games", name = "games-activity", version.ref = "gamesActivity" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidXTestRunner" }
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidXTestRules" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }

View File

@ -4,6 +4,7 @@ use jni::objects::{
use jni::signature::{Primitive, ReturnType};
use jni::JNIEnv;
use ruffle_core::ContextMenuItem;
use std::path::PathBuf;
use std::sync::OnceLock;
/// Handles to various items on the Java `PlayerActivity` class.
@ -16,6 +17,7 @@ pub struct JavaInterface {
show_context_menu: JMethodID,
get_swf_bytes: JMethodID,
get_swf_uri: JMethodID,
get_trace_output: JMethodID,
get_loc_on_screen: JMethodID,
}
@ -109,6 +111,23 @@ impl JavaInterface {
url
}
pub fn get_trace_output(env: &mut JNIEnv, this: &JObject) -> Option<PathBuf> {
let result = unsafe {
env.call_method_unchecked(this, Self::get().get_trace_output, ReturnType::Object, &[])
};
let object = result
.expect("getTraceOutput() must never throw")
.l()
.unwrap();
if object.is_null() {
return None;
}
let string_object = JString::from(object);
let java_string = unsafe { env.get_string_unchecked(&string_object) };
let url = java_string.unwrap().to_string_lossy().to_string();
Some(url.into())
}
pub fn get_loc_on_screen(env: &mut JNIEnv, this: &JObject) -> (i32, i32) {
let result = unsafe {
env.call_method_unchecked(this, Self::get().get_loc_on_screen, ReturnType::Array, &[])
@ -150,6 +169,9 @@ impl JavaInterface {
get_swf_uri: env
.get_method_id(class, "getSwfUri", "()Ljava/lang/String;")
.expect("getSwfUri must exist"),
get_trace_output: env
.get_method_id(class, "getTraceOutput", "()Ljava/lang/String;")
.expect("getTraceOutput must exist"),
get_loc_on_screen: env
.get_method_id(class, "getLocOnScreen", "()[I")
.expect("getLocOnScreen must exist"),

View File

@ -5,6 +5,7 @@ mod java;
mod keycodes;
mod navigator;
mod task;
mod trace;
use custom_event::RuffleEvent;
@ -41,6 +42,7 @@ use ruffle_core::{
};
use crate::keycodes::android_keycode_to_ruffle;
use crate::trace::FileLogBackend;
use java::JavaInterface;
use ruffle_render_wgpu::{backend::WgpuRenderBackend, target::SwapChainTarget};
@ -78,14 +80,14 @@ fn run(app: AndroidApp) {
};
log::info!("Starting event loop...");
let trace_output;
unsafe {
let vm = JavaVM::from_raw(app.vm_as_ptr() as *mut sys::JavaVM).expect("JVM must exist");
let activity = JObject::from_raw(app.activity_as_ptr() as jobject);
let _ = vm
.get_env()
.unwrap()
.set_rust_field(activity, "eventLoopHandle", sender.clone());
let mut jni_env = vm.get_env().unwrap();
trace_output = JavaInterface::get_trace_output(&mut jni_env, &activity);
let _ = jni_env.set_rust_field(activity, "eventLoopHandle", sender.clone());
}
while !quit {
@ -222,6 +224,7 @@ fn run(app: AndroidApp) {
.with_audio(AAudioAudioBackend::new().unwrap())
.with_storage(MemoryStorageBackend::default())
.with_navigator(navigator)
.with_log(FileLogBackend::new(trace_output.as_deref()))
.with_video(
ruffle_video_software::backend::SoftwareVideoBackend::new(),
)

View File

@ -2,12 +2,13 @@
use crate::custom_event::RuffleEvent;
use std::borrow::Cow;
use std::fs::File;
use std::io::Read;
use std::sync::{Arc, Mutex};
use ruffle_core::backend::navigator::{
async_return, create_fetch_error, ErrorResponse, NavigationMethod, NavigatorBackend,
OpenURLMode, OwnedFuture, Request, SuccessResponse,
async_return, create_fetch_error, create_specific_fetch_error, ErrorResponse, NavigationMethod,
NavigatorBackend, OpenURLMode, OwnedFuture, Request, SuccessResponse,
};
use ruffle_core::indexmap::IndexMap;
use ruffle_core::loader::Error;
@ -145,38 +146,52 @@ impl NavigatorBackend for ExternalNavigatorBackend {
fn fetch(&self, request: Request) -> OwnedFuture<Box<dyn SuccessResponse>, ErrorResponse> {
// TODO: honor sandbox type (local-with-filesystem, local-with-network, remote, ...)
let processed_url = match self.resolve_url(request.url()) {
let mut processed_url = match self.resolve_url(request.url()) {
Ok(url) => url,
Err(e) => {
return async_return(create_fetch_error(request.url(), e));
}
};
// TODO: Handle file: and especially content: schemes
// TODO: Handle content: schemes
struct NetworkResponse {
enum AndroidResponseBody {
File(File),
Network(Arc<Mutex<Box<dyn Read + Send + Sync + 'static>>>),
}
struct AndroidResponse {
redirected: bool,
status: u16,
url: String,
reader: Arc<Mutex<Box<dyn Read + Send + Sync + 'static>>>,
response_body: AndroidResponseBody,
length: Option<u64>,
}
impl SuccessResponse for NetworkResponse {
impl SuccessResponse for AndroidResponse {
fn url(&self) -> Cow<str> {
Cow::Borrowed(&self.url)
}
fn body(self: Box<Self>) -> OwnedFuture<Vec<u8>, Error> {
Box::pin(async move {
let mut bytes: Vec<u8> =
Vec::with_capacity(self.length.unwrap_or_default() as usize);
self.reader
.lock()
.expect("working lock during fetch body read")
.read_to_end(&mut bytes)?;
Ok(bytes)
})
let length = self.length.unwrap_or_default() as usize;
match self.response_body {
AndroidResponseBody::File(mut file) => Box::pin(async move {
let mut body = vec![];
file.read_to_end(&mut body)
.map_err(|e| Error::FetchError(e.to_string()))?;
Ok(body)
}),
AndroidResponseBody::Network(response) => Box::pin(async move {
let mut bytes: Vec<u8> = Vec::with_capacity(length);
response
.lock()
.expect("working lock during fetch body read")
.read_to_end(&mut bytes)?;
Ok(bytes)
}),
}
}
fn status(&self) -> u16 {
@ -188,27 +203,46 @@ impl NavigatorBackend for ExternalNavigatorBackend {
}
fn next_chunk(&mut self) -> OwnedFuture<Option<Vec<u8>>, Error> {
let reader = self.reader.clone();
Box::pin(async move {
let mut buf = vec![0; 4096];
let lock = reader.try_lock();
if matches!(lock, Err(std::sync::TryLockError::WouldBlock)) {
return Err(Error::FetchError(
match &mut self.response_body {
AndroidResponseBody::File(file) => {
let mut buf = vec![0; 4096];
let res = file.read(&mut buf);
Box::pin(async move {
match res {
Ok(count) if count > 0 => {
buf.resize(count, 0);
Ok(Some(buf))
}
Ok(_) => Ok(None),
Err(e) => Err(Error::FetchError(e.to_string())),
}
})
}
AndroidResponseBody::Network(response) => {
let reader = response.clone();
Box::pin(async move {
let mut buf = vec![0; 4096];
let lock = reader.try_lock();
if matches!(lock, Err(std::sync::TryLockError::WouldBlock)) {
return Err(Error::FetchError(
"Concurrent read operations on the same stream are not supported."
.to_string(),
));
}
let result = lock.expect("network lock").read(&mut buf);
}
let result = lock.expect("network lock").read(&mut buf);
match result {
Ok(count) if count > 0 => {
buf.resize(count, 0);
Ok(Some(buf))
}
Ok(_) => Ok(None),
Err(e) => Err(Error::FetchError(e.to_string())),
match result {
Ok(count) if count > 0 => {
buf.resize(count, 0);
Ok(Some(buf))
}
Ok(_) => Ok(None),
Err(e) => Err(Error::FetchError(e.to_string())),
}
})
}
})
}
}
fn expected_length(&self) -> Result<Option<u64>, Error> {
@ -216,60 +250,108 @@ impl NavigatorBackend for ExternalNavigatorBackend {
}
}
Box::pin(async move {
let mut ureq_request = ureq::request_url(
match request.method() {
NavigationMethod::Get => "GET",
NavigationMethod::Post => "POST",
},
&processed_url,
);
match processed_url.scheme() {
"file" => Box::pin(async move {
// We send the original url (including query parameters)
// back to ruffle_core in the `Response`
let response_url = processed_url.clone();
// Flash supports query parameters with local urls.
// SwfMovie takes care of exposing those to ActionScript -
// when we actually load a filesystem url, strip them out.
processed_url.set_query(None);
let (body_data, mime) = request.body().clone().unwrap_or_default();
for (name, val) in request.headers().iter() {
ureq_request = ureq_request.set(name, val);
}
ureq_request = ureq_request.set("Content-Type", &mime);
let response = ureq_request.send_bytes(&body_data).map_err(|e| {
log::warn!("Error fetching url: {e}");
let inner = match e.kind() {
ureq::ErrorKind::Dns => Error::InvalidDomain(processed_url.to_string()),
_ => Error::FetchError(e.to_string()),
let path = match processed_url.to_file_path() {
Ok(path) => path,
Err(_) => {
return create_specific_fetch_error(
"Unable to create path out of URL",
response_url.as_str(),
"",
);
}
};
ErrorResponse {
url: processed_url.to_string(),
error: inner,
let contents = std::fs::File::open(path);
let file = match contents {
Ok(file) => file,
Err(e) => {
return create_specific_fetch_error(
"Can't open file",
response_url.as_str(),
e,
);
}
};
let length = file.metadata().map(|m| m.len()).ok();
let response: Box<dyn SuccessResponse> = Box::new(AndroidResponse {
url: response_url.to_string(),
response_body: AndroidResponseBody::File(file),
status: 0,
redirected: false,
length,
});
Ok(response)
}),
_ => Box::pin(async move {
let mut ureq_request = ureq::request_url(
match request.method() {
NavigationMethod::Get => "GET",
NavigationMethod::Post => "POST",
},
&processed_url,
);
let (body_data, mime) = request.body().clone().unwrap_or_default();
for (name, val) in request.headers().iter() {
ureq_request = ureq_request.set(name, val);
}
})?;
ureq_request = ureq_request.set("Content-Type", &mime);
let response = ureq_request.send_bytes(&body_data).map_err(|e| {
log::warn!("Error fetching url: {e}");
let inner = match e.kind() {
ureq::ErrorKind::Dns => Error::InvalidDomain(processed_url.to_string()),
_ => Error::FetchError(e.to_string()),
};
ErrorResponse {
url: processed_url.to_string(),
error: inner,
}
})?;
let status = response.status();
let redirected = response.get_url() != processed_url.as_str();
let response_length = response
.header("Content-Length")
.and_then(|s| s.parse::<u64>().ok());
let status = response.status();
let redirected = response.get_url() != processed_url.as_str();
let response_length = response
.header("Content-Length")
.and_then(|s| s.parse::<u64>().ok());
if !(200..300).contains(&status) {
let error = Error::HttpNotOk(
format!("HTTP status is not ok, got {}", response.status()),
if !(200..300).contains(&status) {
let error = Error::HttpNotOk(
format!("HTTP status is not ok, got {}", response.status()),
status,
redirected,
response_length.unwrap_or_default(),
);
return Err(ErrorResponse {
url: response.get_url().to_string(),
error,
});
}
let response: Box<dyn SuccessResponse> = Box::new(AndroidResponse {
url: response.get_url().to_string(),
response_body: AndroidResponseBody::Network(Arc::new(Mutex::new(
response.into_reader(),
))),
status,
redirected,
response_length.unwrap_or_default(),
);
return Err(ErrorResponse {
url: response.get_url().to_string(),
error,
length: response_length,
});
}
let response: Box<dyn SuccessResponse> = Box::new(NetworkResponse {
url: response.get_url().to_string(),
reader: Arc::new(Mutex::new(response.into_reader())),
status,
redirected,
length: response_length,
});
Ok(response)
})
Ok(response)
}),
}
}
fn spawn_future(&mut self, future: OwnedFuture<(), Error>) {

30
src/trace.rs Normal file
View File

@ -0,0 +1,30 @@
use ruffle_core::backend::log::LogBackend;
use std::cell::RefCell;
use std::fs::File;
use std::io::{LineWriter, Write};
use std::path::Path;
pub struct FileLogBackend {
writer: Option<RefCell<LineWriter<File>>>,
}
impl FileLogBackend {
pub fn new(path: Option<&Path>) -> Self {
Self {
writer: path
.map(|path| File::create(path).unwrap())
.map(LineWriter::new)
.map(RefCell::new),
}
}
}
impl LogBackend for FileLogBackend {
fn avm_trace(&self, message: &str) {
log::info!("avm_trace: {message}");
if let Some(writer) = &self.writer {
writer.borrow_mut().write_all(message.as_bytes()).unwrap();
writer.borrow_mut().write_all("\n".as_bytes()).unwrap();
}
}
}