use crate::errors;
use crate::runtime;
use crate::sdk::Sdk;
use crate::store::{State, Store, now_secs};
use anyhow::{Context, Result, bail};
use std::collections::HashMap;
use std::io::Read;
use std::process::Stdio;
use std::time::{Duration, Instant};
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Report {
pub passed: u32,
pub failed: u32,
pub errors: u32,
pub ignored: u32,
pub failures: Vec<Failure>,
pub final_code: Option<i32>,
pub crashed: bool,
pub failed_to_start: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct Failure {
pub class: String,
pub test: String,
pub stack: String,
}
impl Report {
pub fn ok(&self) -> bool {
self.failed_to_start.is_none()
&& !self.crashed
&& self.failed == 0
&& self.errors == 0
&& self.final_code == Some(-1)
}
}
pub fn parse(output: &str) -> Report {
let mut r = Report::default();
let mut block: HashMap<String, String> = HashMap::new();
let mut last_key: Option<String> = None;
for line in output.lines() {
let line = line.trim_end_matches('\r');
if let Some(rest) = line.strip_prefix("INSTRUMENTATION_STATUS_CODE:") {
let code: i32 = rest.trim().parse().unwrap_or(i32::MIN);
let get = |k: &str| block.get(k).cloned().unwrap_or_default();
if let Some(err) = block.get("Error") {
r.failed_to_start = Some(err.clone()); block.clear();
last_key = None;
continue;
}
match code {
0 => r.passed += 1,
-1 => {
r.errors += 1;
r.failures.push(Failure {
class: get("class"),
test: get("test"),
stack: get("stack"),
});
}
-2 => {
r.failed += 1;
r.failures.push(Failure {
class: get("class"),
test: get("test"),
stack: get("stack"),
});
}
-3 | -4 => r.ignored += 1,
_ => {} }
block.clear();
last_key = None;
} else if let Some(rest) = line.strip_prefix("INSTRUMENTATION_STATUS:") {
if let Some((k, v)) = rest.trim_start().split_once('=') {
block.insert(k.to_string(), v.to_string());
last_key = Some(k.to_string());
}
} else if let Some(rest) = line.strip_prefix("INSTRUMENTATION_CODE:") {
r.final_code = rest.trim().parse().ok();
last_key = None;
} else if line.starts_with("INSTRUMENTATION_RESULT:") {
if line.contains("shortMsg=Process crashed") {
r.crashed = true;
}
last_key = None;
} else if line.contains("INSTRUMENTATION_FAILED") && r.failed_to_start.is_none() {
r.failed_to_start = Some(line.trim().to_string());
} else if let Some(k) = &last_key {
if let Some(v) = block.get_mut(k) {
v.push('\n');
v.push_str(line);
}
}
}
r
}
fn safe(s: &str, extra: &str) -> bool {
!s.is_empty()
&& s.chars().all(|c| {
c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '-') || extra.contains(c)
})
}
pub fn report_json(r: &Report, timed_out: bool, logdir: &std::path::Path) -> serde_json::Value {
serde_json::json!({
"ok": r.ok() && !timed_out,
"passed": r.passed,
"failed": r.failed,
"errors": r.errors,
"ignored": r.ignored,
"failures": r.failures.iter().map(|f| serde_json::json!({
"class": f.class, "test": f.test, "stack": f.stack,
})).collect::<Vec<_>>(),
"timed_out": timed_out,
"crashed": r.crashed,
"failed_to_start": r.failed_to_start,
"log_dir": logdir,
})
}
pub fn run(
key: &str,
runner: &str,
class: Option<&str>,
timeout: u64,
json: bool,
out: Option<&std::path::Path>,
) -> Result<i32> {
let cfg = errors::with(errors::CONFIG);
if !safe(runner, "") || !runner.contains('/') {
return Err(cfg(anyhow::anyhow!(
"invalid --runner {runner:?}: expected <package>/<InstrumentationClass>"
)));
}
if let Some(c) = class {
if !safe(c, "#,") {
return Err(cfg(anyhow::anyhow!("invalid --class {c:?}")));
}
}
let sdk = Sdk::locate().map_err(errors::with(errors::DEPENDENCY))?;
let store = Store::open()?;
let mut inst = store.find(key)?;
runtime::reconcile(&store, &mut inst)?;
if inst.state != State::Ready {
bail!(
"instance {} is {}, not READY\n\nRun `dorker ps -a` to inspect it.",
inst.name,
inst.state.as_str()
);
}
let serial = inst.serial();
let logdir = store
.instance_dir(&inst.id)
.join(format!("test-{}", now_secs()));
std::fs::create_dir_all(&logdir)?;
let mut remote = String::from("am instrument -w -r");
if let Some(c) = class {
remote.push_str(&format!(" -e class {c}"));
}
remote.push(' ');
remote.push_str(runner);
let _ = runtime::adb(&sdk, &serial).args(["logcat", "-c"]).output();
let mut child = runtime::adb(&sdk, &serial)
.args(["shell", &remote])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("running adb")?;
let mut out_pipe = child.stdout.take().unwrap();
let mut err_pipe = child.stderr.take().unwrap();
let out_t = std::thread::spawn(move || {
let mut s = String::new();
let _ = out_pipe.read_to_string(&mut s);
s
});
let err_t = std::thread::spawn(move || {
let mut s = String::new();
let _ = err_pipe.read_to_string(&mut s);
s
});
let start = Instant::now();
let mut timed_out = false;
loop {
if child.try_wait()?.is_some() {
break;
}
if start.elapsed() > Duration::from_secs(timeout) || runtime::interrupted() {
timed_out = true;
let _ = child.kill();
let _ = child.wait();
break;
}
std::thread::sleep(Duration::from_millis(200));
}
let stdout = out_t.join().unwrap_or_default();
let stderr = err_t.join().unwrap_or_default();
std::fs::write(logdir.join("instrument.txt"), format!("{stdout}{stderr}"))?;
if let Ok(o) = runtime::adb(&sdk, &serial).args(["logcat", "-d"]).output() {
let _ = std::fs::write(logdir.join("logcat.txt"), o.stdout);
}
let mut logdir_shown = logdir.clone();
if let Some(dest) = out {
std::fs::create_dir_all(dest)
.map_err(|e| anyhow::anyhow!("creating --out {}: {e}", dest.display()))?;
for name in ["instrument.txt", "logcat.txt"] {
if logdir.join(name).is_file() {
std::fs::copy(logdir.join(name), dest.join(name))
.map_err(|e| anyhow::anyhow!("copying {name} to {}: {e}", dest.display()))?;
}
}
logdir_shown = dest.to_path_buf();
}
let logdir = logdir_shown;
let report = parse(&format!("{stdout}{stderr}"));
if json {
println!(
"{}",
serde_json::to_string_pretty(&report_json(&report, timed_out, &logdir))?
);
} else {
println!(
"Tests: {} passed, {} failed, {} errors, {} ignored",
report.passed, report.failed, report.errors, report.ignored
);
for f in &report.failures {
println!("\nFAILED {}#{}\n{}", f.class, f.test, f.stack.trim_end());
}
}
if timed_out {
eprintln!("error: instrumentation did not finish within {timeout}s");
}
if let Some(m) = &report.failed_to_start {
eprintln!("error: {m}");
let listed = runtime::adb(&sdk, &serial)
.args(["shell", "pm", "list", "instrumentation"])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
if listed.is_empty() {
eprintln!(
"No instrumentation is installed in this instance. Install a test APK with `dorker install`."
);
} else {
eprintln!("Instrumentation installed in this instance:");
for l in listed.lines() {
eprintln!(" {}", l.trim_start_matches("instrumentation:"));
}
}
}
if report.crashed {
eprintln!("error: the instrumentation process crashed");
}
if report.ok() && !timed_out {
if !json {
println!("\nPASSED (logs: {})", logdir.display());
}
Ok(0)
} else {
eprintln!("\nFAILED. Logs preserved in {}", logdir.display());
Ok(errors::TEST)
}
}
#[cfg(test)]
mod tests {
use super::*;
const PASS: &str = "INSTRUMENTATION_STATUS: numtests=2\nINSTRUMENTATION_STATUS: class=a.B\nINSTRUMENTATION_STATUS: test=one\nINSTRUMENTATION_STATUS_CODE: 1\n\
INSTRUMENTATION_STATUS: class=a.B\nINSTRUMENTATION_STATUS: test=one\nINSTRUMENTATION_STATUS_CODE: 0\n\
INSTRUMENTATION_STATUS: class=a.B\nINSTRUMENTATION_STATUS: test=two\nINSTRUMENTATION_STATUS_CODE: 0\n\
INSTRUMENTATION_RESULT: stream=\n\nTime: 0.1\n\nOK (2 tests)\n\nINSTRUMENTATION_CODE: -1\n";
#[test]
fn passing_run() {
let r = parse(PASS);
assert_eq!((r.passed, r.failed, r.errors), (2, 0, 0));
assert!(r.ok());
}
#[test]
fn failing_test_with_multiline_stack() {
let out = "INSTRUMENTATION_STATUS: class=a.B\nINSTRUMENTATION_STATUS: test=boom\n\
INSTRUMENTATION_STATUS: stack=java.lang.AssertionError: x\n\tat a.B.boom(B.java:9)\n\tat more\n\
INSTRUMENTATION_STATUS_CODE: -2\nINSTRUMENTATION_CODE: -1\n";
let r = parse(out);
assert_eq!(r.failed, 1);
assert!(!r.ok());
assert_eq!(r.failures[0].test, "boom");
assert!(r.failures[0].stack.contains("at a.B.boom"));
assert!(r.failures[0].stack.starts_with("java.lang.AssertionError"));
}
#[test]
fn crash_start_failure_and_missing_final_code() {
let r =
parse("INSTRUMENTATION_RESULT: shortMsg=Process crashed.\nINSTRUMENTATION_CODE: 0\n");
assert!(r.crashed && !r.ok());
let r = parse("onError: commandError=true message=INSTRUMENTATION_FAILED: com.nope/X\n");
assert!(r.failed_to_start.is_some() && !r.ok());
assert!(!parse("INSTRUMENTATION_STATUS_CODE: 0\n").ok());
let r = parse(
"INSTRUMENTATION_STATUS_CODE: -3\nINSTRUMENTATION_STATUS_CODE: -4\nINSTRUMENTATION_CODE: -1\n",
);
assert_eq!(r.ignored, 2);
assert!(r.ok());
}
#[test]
fn start_failure_is_not_a_test_error() {
let out = "onError: commandError=true message=INSTRUMENTATION_FAILED: com.nope/com.nope.X\n\
INSTRUMENTATION_STATUS: Error=Unable to find instrumentation info for: ComponentInfo{com.nope/com.nope.X}\n\
INSTRUMENTATION_STATUS: id=ActivityManagerService\nINSTRUMENTATION_STATUS_CODE: -1\n\
android.util.AndroidException: INSTRUMENTATION_FAILED: com.nope/com.nope.X\n\tat com.android.commands.am.Instrument.run(Instrument.java:543)\n";
let r = parse(out);
assert_eq!(r.errors, 0);
assert!(
r.failed_to_start
.as_deref()
.unwrap()
.contains("Unable to find instrumentation")
);
assert!(!r.ok());
}
#[test]
fn argument_validation() {
assert!(safe(
"com.x.test/androidx.test.runner.AndroidJUnitRunner",
""
));
assert!(!safe("a; rm -rf /", ""));
assert!(!safe("$(id)", ""));
assert!(safe("a.B#m,a.C", "#,"));
assert!(!safe("a.B#m", ""));
}
}