use std::path::{Path, PathBuf};
use std::process::{Command, Output};
struct Env {
home: PathBuf,
}
impl Env {
fn new(name: &str) -> Self {
let home = std::env::temp_dir().join(format!("dorker-it-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
std::fs::create_dir_all(&home).unwrap();
Env { home }
}
fn cmd(&self, args: &[&str]) -> Command {
let mut c = Command::new(env!("CARGO_BIN_EXE_dorker"));
c.args(args)
.env("DORKER_HOME", &self.home)
.env("DORKER_CONFIG", self.home.join("test-config.toml"));
c
}
fn run(&self, args: &[&str]) -> Output {
self.cmd(args).output().unwrap()
}
}
impl Drop for Env {
fn drop(&mut self) {
if let Ok(out) = Command::new(env!("CARGO_BIN_EXE_dorker"))
.args(["ps", "-a", "--json"])
.env("DORKER_HOME", &self.home)
.env("DORKER_CONFIG", self.home.join("test-config.toml"))
.output()
{
if let Ok(rows) = serde_json::from_slice::<Vec<serde_json::Value>>(&out.stdout) {
for r in rows {
let _ = Command::new(env!("CARGO_BIN_EXE_dorker"))
.args(["rm", "-f", r["id"].as_str().unwrap_or("")])
.env("DORKER_HOME", &self.home)
.env("DORKER_CONFIG", self.home.join("test-config.toml"))
.output();
}
}
}
let _ = std::fs::remove_dir_all(&self.home);
}
}
fn text(b: &[u8]) -> String {
String::from_utf8_lossy(b).into_owned()
}
fn code(o: &Output) -> i32 {
o.status.code().unwrap_or(-1)
}
fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
let p = dir.join(name);
std::fs::write(&p, body).unwrap();
p
}
#[test]
fn empty_state_lists_nothing() {
let e = Env::new("empty");
let ps = e.run(&["ps", "-a"]);
assert_eq!(code(&ps), 0);
assert_eq!(text(&ps.stdout).lines().count(), 1, "header only");
assert_eq!(code(&e.run(&["images"])), 0);
assert_eq!(text(&e.run(&["ps", "--json"]).stdout).trim(), "[]");
}
#[test]
fn unknown_instance_and_image_are_errors() {
let e = Env::new("unknown");
let o = e.run(&["stop", "nope"]);
assert_eq!(code(&o), 1);
assert!(text(&o.stderr).contains("no such instance"));
assert_eq!(code(&e.run(&["rmi", "nope"])), 1);
}
#[test]
fn dorkerfile_errors_use_exit_code_2_and_show_position() {
let e = Env::new("parse");
let ctx = e.home.join("ctx");
std::fs::create_dir_all(&ctx).unwrap();
write(&ctx, "Dorkerfile", "FROM android:35\n\nMEM 2GB\n");
let o = e.run(&["build", ctx.to_str().unwrap()]);
assert_eq!(code(&o), 2);
let err = text(&o.stderr);
assert!(
err.starts_with("Dorkerfile:3:1\nerror: unsupported directive \"MEM\""),
"{err}"
);
assert!(err.contains("Did you mean \"MEMORY\"?"));
assert_eq!(
err.matches("error:").count(),
1,
"no duplicated prefix: {err}"
);
}
#[test]
fn missing_sdk_is_exit_code_3() {
let e = Env::new("nosdk");
let o = e
.cmd(&["run", "-d", "android:35"])
.env("ANDROID_HOME", "/nonexistent")
.env("ANDROID_SDK_ROOT", "/nonexistent")
.env("HOME", "/nonexistent")
.output()
.unwrap();
assert_eq!(code(&o), 3, "{}", text(&o.stderr));
assert!(text(&o.stderr).contains("Android SDK not found"));
}
#[test]
fn invalid_arguments_are_exit_code_2() {
let e = Env::new("args");
let o = e.run(&["run", "-d", "android:35", "--memory", "banana"]);
assert!(matches!(code(&o), 2 | 3), "{}", text(&o.stderr));
let o = e.run(&["test", "x", "--runner", "bad; reboot"]);
assert_eq!(code(&o), 2);
}
#[test]
fn insufficient_disk_space_is_a_resource_conflict_before_anything_is_created() {
let e = Env::new("disk");
let o = e
.cmd(&["run", "-d", "android:35"])
.env("DORKER_MIN_FREE_MB", "999999999999")
.output()
.unwrap();
assert_eq!(code(&o), 8, "{}", text(&o.stderr));
let err = text(&o.stderr);
assert!(err.contains("not enough free disk space"), "{err}");
assert!(
err.contains("DORKER_MIN_FREE_MB"),
"tells the user how to adjust it: {err}"
);
assert_eq!(
std::fs::read_dir(e.home.join("instances")).unwrap().count(),
0,
"nothing created"
);
}
fn mcp_session(
e: &Env,
messages: &[serde_json::Value],
raw_extra: &[&str],
) -> Vec<serde_json::Value> {
use std::io::Write;
use std::process::Stdio;
let mut child = e
.cmd(&["mcp"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
{
let stdin = child.stdin.as_mut().unwrap();
for m in messages {
writeln!(stdin, "{m}").unwrap();
}
for r in raw_extra {
writeln!(stdin, "{r}").unwrap();
}
}
drop(child.stdin.take()); let out = child.wait_with_output().unwrap();
assert_eq!(
code(&Output {
status: out.status,
stdout: vec![],
stderr: vec![]
}),
0,
"server exits cleanly on EOF"
);
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| {
serde_json::from_str(l)
.unwrap_or_else(|e| panic!("stdout must carry only JSON-RPC, got {l:?}: {e}"))
})
.collect()
}
#[test]
fn mcp_server_speaks_the_protocol_over_stdio() {
use serde_json::json;
let e = Env::new("mcp");
let rpc = |id: u32, method: &str, params: serde_json::Value| json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
let msgs = [
rpc(
1,
"initialize",
json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "0"}}),
),
json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
rpc(2, "tools/list", json!({})),
rpc(
3,
"tools/call",
json!({"name": "dorker_ps", "arguments": {"all": true}}),
),
rpc(
4,
"tools/call",
json!({"name": "dorker_run", "arguments": {}}),
),
rpc(
5,
"tools/call",
json!({"name": "dorker_exec", "arguments": {"instance": "nope", "command": "ls"}}),
),
rpc(
6,
"tools/call",
json!({"name": "dorker_doctor", "arguments": {}}),
),
rpc(7, "no/such/method", json!({})),
];
let out = mcp_session(&e, &msgs, &["this is not json"]);
let by_id = |id: u64| {
out.iter()
.find(|m| m["id"] == id)
.unwrap_or_else(|| panic!("no response for id {id}: {out:?}"))
};
assert_eq!(by_id(1)["result"]["protocolVersion"], "2025-06-18");
assert_eq!(by_id(1)["result"]["serverInfo"]["name"], "dorker");
let tools = by_id(2)["result"]["tools"].as_array().unwrap().clone();
assert!(tools.len() >= 12, "{} tools", tools.len());
assert!(out.iter().filter(|m| m["id"].is_u64()).count() == 7);
assert_eq!(by_id(3)["result"]["isError"], false);
assert_eq!(
by_id(3)["result"]["structuredContent"]["instances"],
json!([])
);
assert_eq!(by_id(4)["result"]["isError"], true);
assert!(
by_id(4)["result"]["content"][0]["text"]
.as_str()
.unwrap()
.contains("missing required argument `image`")
);
assert_eq!(by_id(5)["result"]["isError"], true);
assert!(
by_id(5)["result"]["content"][0]["text"]
.as_str()
.unwrap()
.contains("no such instance")
);
assert!(by_id(6)["result"]["structuredContent"]["ok"].is_boolean());
assert_eq!(by_id(7)["error"]["code"], -32601);
assert!(
out.iter()
.any(|m| m["id"].is_null() && m["error"]["code"] == -32700),
"{out:?}"
);
}
#[test]
fn json_flags_report_failures_as_json_or_plain_errors() {
let e = Env::new("jsonflags");
let o = e.run(&["run", "--json", "android:35"]);
assert_eq!(code(&o), 2, "{}", text(&o.stderr));
assert!(text(&o.stderr).contains("--json needs --detach"));
let o = e.run(&["exec", "--json", "nope", "ls"]);
assert_eq!(code(&o), 1);
assert!(text(&o.stdout).trim().is_empty());
}
#[test]
fn pull_without_an_image_reads_the_dorkerfile() {
let e = Env::new("pull");
let dir = e.home.join("proj");
std::fs::create_dir_all(&dir).unwrap();
let pull = |args: &[&str]| {
e.cmd(args)
.current_dir(&dir)
.env("ANDROID_HOME", "/nonexistent")
.env("ANDROID_SDK_ROOT", "/nonexistent")
.env("HOME", "/nonexistent")
.output()
.unwrap()
};
let o = pull(&["pull"]);
assert_eq!(code(&o), 2, "{}", text(&o.stderr));
assert!(
text(&o.stderr).contains("dorker pull android:35"),
"{}",
text(&o.stderr)
);
write(&dir, "Dorkerfile", "FROM android:35\nMEM 2GB\n");
let o = pull(&["pull"]);
assert_eq!(code(&o), 2);
assert!(
text(&o.stderr).starts_with("Dorkerfile:2:1"),
"{}",
text(&o.stderr)
);
write(
&dir,
"Dorkerfile",
"FROM android:36.1-google_apis_playstore\n",
);
let o = pull(&["pull"]);
assert_eq!(code(&o), 3, "{}", text(&o.stderr));
assert!(
text(&o.stderr).contains("Dorkerfile asks for android:36.1-google_apis_playstore"),
"{}",
text(&o.stderr)
);
write(&dir, "Other.dorker", "FROM android:34\n");
let o = pull(&["pull", "-f", "Other.dorker"]);
assert!(
text(&o.stderr).contains("Dorkerfile asks for android:34")
|| text(&o.stderr).contains("Other.dorker asks"),
"{}",
text(&o.stderr)
);
}
#[test]
fn doctor_colour_follows_the_conventions() {
let e = Env::new("colour");
let doctor = |args: &[&str], vars: &[(&str, &str)]| {
let mut c = e.cmd(args);
c.env_remove("NO_COLOR")
.env_remove("CLICOLOR_FORCE")
.env_remove("CLICOLOR")
.env("ANDROID_HOME", "/nonexistent")
.env("ANDROID_SDK_ROOT", "/nonexistent")
.env("HOME", "/nonexistent");
for (k, v) in vars {
c.env(k, v);
}
c.output().unwrap()
};
let esc = |o: &Output| text(&o.stdout).contains('\x1b');
let piped = doctor(&["doctor"], &[]);
assert!(!esc(&piped), "{}", text(&piped.stdout));
assert!(
text(&piped.stdout).contains("[FAIL] android sdk"),
"{}",
text(&piped.stdout)
);
let forced = doctor(&["doctor"], &[("CLICOLOR_FORCE", "1")]);
assert!(esc(&forced));
assert!(
text(&forced.stdout).contains("\x1b[1;31mFAIL\x1b[0m"),
"{}",
text(&forced.stdout)
);
assert_eq!(code(&forced), code(&piped));
let stripped: String = {
let mut out = String::new();
let mut chars = text(&forced.stdout).chars().collect::<Vec<_>>().into_iter();
while let Some(c) = chars.next() {
if c == '\x1b' {
for n in chars.by_ref() {
if n.is_ascii_alphabetic() {
break;
}
}
} else {
out.push(c);
}
}
out
};
assert_eq!(stripped, text(&piped.stdout));
let no = doctor(&["doctor"], &[("CLICOLOR_FORCE", "1"), ("NO_COLOR", "1")]);
assert!(!esc(&no));
let json = doctor(&["doctor", "--json"], &[("CLICOLOR_FORCE", "1")]);
assert!(!esc(&json));
assert!(serde_json::from_slice::<serde_json::Value>(&json.stdout).is_ok());
}
#[test]
fn versions_without_an_sdk_is_a_missing_dependency() {
let e = Env::new("versions-nosdk");
let o = e
.cmd(&["versions"])
.env("ANDROID_HOME", "/nonexistent")
.env("ANDROID_SDK_ROOT", "/nonexistent")
.env("HOME", "/nonexistent")
.output()
.unwrap();
assert_eq!(code(&o), 3, "{}", text(&o.stderr));
assert!(text(&o.stderr).contains("Android SDK not found"));
}
#[test]
fn ls_on_an_empty_store_explains_how_to_get_started() {
let e = Env::new("ls-empty");
let o = e
.cmd(&["ls"])
.env("ANDROID_HOME", "/nonexistent")
.env("ANDROID_SDK_ROOT", "/nonexistent")
.env("HOME", "/nonexistent")
.output()
.unwrap();
assert_eq!(code(&o), 0, "{}", text(&o.stderr));
let out = text(&o.stdout);
assert!(
out.contains("IMAGES") && out.contains("(none yet:"),
"{out}"
);
assert!(
out.contains("INSTANCES\n (none)") && out.contains("STORAGE"),
"{out}"
);
assert!(
!out.contains("Android SDK system images"),
"no SDK, so that line is omitted: {out}"
);
let v: serde_json::Value = serde_json::from_slice(&e.run(&["ls", "--json"]).stdout).unwrap();
assert_eq!(v["images"], serde_json::json!([]));
assert_eq!(v["storage"]["images_bytes"], 0);
}
#[test]
fn prune_on_an_empty_store_is_a_no_op_and_json_needs_an_explicit_choice() {
let e = Env::new("prune-empty");
let o = e.run(&["prune"]);
assert_eq!(code(&o), 0, "{}", text(&o.stderr));
assert_eq!(text(&o.stdout).trim(), "Nothing to prune.");
assert_eq!(code(&e.run(&["prune", "--json"])), 2);
let o = e.run(&["prune", "--json", "--dry-run"]);
let v: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap();
assert_eq!(v["reclaimable_bytes"], 0);
}
#[test]
fn init_creates_a_parseable_dorkerfile_and_never_overwrites() {
let e = Env::new("init");
let dir = e.home.join("proj");
std::fs::create_dir_all(&dir).unwrap();
let o = e.cmd(&["init"]).current_dir(&dir).output().unwrap();
assert_eq!(code(&o), 0);
let body = std::fs::read_to_string(dir.join("Dorkerfile")).unwrap();
assert!(body.starts_with("FROM android:35"));
let again = e.cmd(&["init"]).current_dir(&dir).output().unwrap();
assert_eq!(code(&again), 8, "refuses to overwrite");
assert_eq!(
std::fs::read_to_string(dir.join("Dorkerfile")).unwrap(),
body
);
}
#[test]
fn templates_list_show_and_init() {
let e = Env::new("templates");
let list = e.cmd(&["templates"]).output().unwrap();
assert_eq!(code(&list), 0);
let table = text(&list.stdout);
for name in [
"basic",
"android-auto",
"playstore",
"instrumentation-tests",
"screenshots",
] {
assert!(table.contains(name), "{name} missing:\n{table}");
}
let json: serde_json::Value =
serde_json::from_slice(&e.cmd(&["templates", "--json"]).output().unwrap().stdout).unwrap();
assert!(json.as_array().unwrap().iter().any(|t| {
t["name"] == "android-auto-with-dev-mode"
&& t["files"]
.as_array()
.unwrap()
.iter()
.any(|f| f == "enable-dev-mode.sh")
}));
assert_eq!(
text(&e.cmd(&["init", "--list"]).output().unwrap().stdout),
table
);
let show = e
.cmd(&["templates", "android-auto-with-dev-mode"])
.output()
.unwrap();
let shown = text(&show.stdout);
assert!(
shown.contains("enable-dev-mode.sh (executable)")
&& shown.contains("./fetch.sh")
&& shown.contains("Notes"),
"{shown}"
);
let dir = e.home.join("auto");
std::fs::create_dir_all(&dir).unwrap();
let o = e
.cmd(&["init", "--template", "android-auto-with-dev-mode"])
.current_dir(&dir)
.output()
.unwrap();
assert_eq!(code(&o), 0, "{}", text(&o.stderr));
for f in ["Dorkerfile", "enable-dev-mode.sh", "fetch.sh"] {
assert!(dir.join(f).is_file(), "{f}");
}
assert!(
!dir.join("README.md").exists(),
"the template README is shown, not copied"
);
assert!(!dir.join("template.toml").exists());
{
use std::os::unix::fs::PermissionsExt;
let mode = |f: &str| std::fs::metadata(dir.join(f)).unwrap().permissions().mode();
assert_ne!(mode("enable-dev-mode.sh") & 0o111, 0);
assert_eq!(mode("Dorkerfile") & 0o111, 0);
}
assert!(
std::fs::read_to_string(dir.join("Dorkerfile"))
.unwrap()
.contains("PROVISION ./enable-dev-mode.sh")
);
let again = e
.cmd(&["init", "--template", "android-auto-with-dev-mode"])
.current_dir(&dir)
.output()
.unwrap();
assert_eq!(code(&again), 8, "refuses to overwrite");
assert!(text(&again.stderr).contains("Dorkerfile") && text(&again.stderr).contains("--force"));
std::fs::write(dir.join("Dorkerfile"), "mine").unwrap();
let forced = e
.cmd(&[
"init",
"--template",
"android-auto-with-dev-mode",
"--force",
])
.current_dir(&dir)
.output()
.unwrap();
assert_eq!(code(&forced), 0);
assert!(
std::fs::read_to_string(dir.join("Dorkerfile"))
.unwrap()
.starts_with('#')
);
let plain = e.home.join("plain");
let o = e
.cmd(&[
"init",
"--template",
"android-auto",
plain.to_str().unwrap(),
])
.output()
.unwrap();
assert_eq!(code(&o), 0, "{}", text(&o.stderr));
assert!(plain.join("fetch.sh").is_file() && !plain.join("enable-dev-mode.sh").exists());
let plain_df = std::fs::read_to_string(plain.join("Dorkerfile")).unwrap();
assert!(plain_df.contains("INSTALL ./com.google.android.projection.gearhead.xapk"));
assert!(!plain_df.contains("PROVISION"), "{plain_df}");
let target = e.home.join("shots/nested");
let o = e
.cmd(&[
"init",
"--template",
"screenshots",
target.to_str().unwrap(),
])
.output()
.unwrap();
assert_eq!(code(&o), 0, "{}", text(&o.stderr));
assert!(target.join("screenshots.sh").is_file());
let bad = e.cmd(&["init", "--template", "auto"]).output().unwrap();
assert_eq!(code(&bad), 2);
let err = text(&bad.stderr);
assert!(
err.contains("Did you mean: android-auto") && err.contains("Available templates"),
"{err}"
);
assert_eq!(code(&e.cmd(&["templates", "nope"]).output().unwrap()), 2);
}
#[test]
fn config_file_sets_where_things_live_and_the_environment_wins() {
let e = Env::new("config");
let cfg = e.home.join("test-config.toml");
let json = |c: &mut Command| -> serde_json::Value {
let o = c.output().unwrap();
assert_eq!(code(&o), 0, "{}", text(&o.stderr));
serde_json::from_slice(&o.stdout).unwrap()
};
let none = e.run(&["config"]);
assert_eq!(code(&none), 0);
let shown = text(&none.stdout);
assert!(
shown.contains("none yet") && shown.contains("min_free_mb"),
"{shown}"
);
assert_eq!(
text(&e.run(&["config", "path"]).stdout).trim(),
cfg.to_str().unwrap()
);
let init = e.run(&["config", "init"]);
assert_eq!(code(&init), 0, "{}", text(&init.stderr));
assert!(std::fs::read_to_string(&cfg).unwrap().contains("# home ="));
assert_eq!(code(&e.run(&["config", "init"])), 8, "never overwrites");
assert_eq!(code(&e.run(&["config", "init", "--force"])), 0);
let store = e.home.join("big-disk/dorker");
std::fs::write(
&cfg,
format!(
"home = \"{}\"\nmin_free_mb = 1234\ngpu = \"guest\"\n",
store.display()
),
)
.unwrap();
let mut from_file = e.cmd(&["config", "--json"]);
from_file.env_remove("DORKER_HOME");
let v = json(&mut from_file);
assert_eq!(v["settings"]["home"]["value"], store.to_str().unwrap());
assert_eq!(v["settings"]["home"]["source"], "config file");
assert_eq!(v["settings"]["min_free_mb"]["value"], "1234");
assert_eq!(v["settings"]["gpu"]["source"], "config file");
let mut images = e.cmd(&["images"]);
images.env_remove("DORKER_HOME");
assert_eq!(code(&images.output().unwrap()), 0);
assert!(
store.join("images").is_dir(),
"the store was created where the file says"
);
let other = e.home.join("from-env");
let mut env_wins = e.cmd(&["config", "--json"]);
env_wins
.env("DORKER_HOME", &other)
.env("DORKER_MIN_FREE_MB", "77");
let v = json(&mut env_wins);
assert_eq!(v["settings"]["home"]["value"], other.to_str().unwrap());
assert_eq!(v["settings"]["home"]["source"], "DORKER_HOME");
assert_eq!(v["settings"]["min_free_mb"]["source"], "DORKER_MIN_FREE_MB");
let sdk = e.home.join("my-sdk");
std::fs::create_dir_all(sdk.join("platform-tools")).unwrap();
std::fs::write(&cfg, format!("sdk = \"{}\"\n", sdk.display())).unwrap();
let mut with_sdk = e.cmd(&["config", "--json"]);
with_sdk
.env_remove("ANDROID_HOME")
.env_remove("ANDROID_SDK_ROOT")
.env("HOME", &e.home);
let v = json(&mut with_sdk);
assert_eq!(v["settings"]["sdk"]["value"], sdk.to_str().unwrap());
assert_eq!(v["settings"]["sdk"]["source"], "config file");
std::fs::write(&cfg, "hom = \"/x\"\n").unwrap();
let bad = e.run(&["images"]);
assert_eq!(code(&bad), 2);
let err = text(&bad.stderr);
assert!(
err.contains(cfg.to_str().unwrap()) && err.contains("hom"),
"{err}"
);
assert_eq!(code(&e.run(&["config"])), 2);
assert_eq!(code(&e.run(&["config", "path"])), 0);
assert_eq!(code(&e.run(&["templates"])), 0);
std::fs::write(&cfg, "gpu = \"fast\"\n").unwrap();
assert!(text(&e.run(&["images"]).stderr).contains("swiftshader_indirect"));
}
fn example_apks() -> PathBuf {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/instrumentation");
if !dir.join("test.apk").exists() {
let st = Command::new(dir.join("make-apk.sh"))
.status()
.expect("make-apk.sh");
assert!(
st.success(),
"building example APKs needs the Android build-tools and a JDK"
);
}
dir
}
fn adb_ok(e: &Env, args: &[&str]) -> String {
let o = e.run(args);
assert_eq!(code(&o), 0, "{args:?} failed: {}", text(&o.stderr));
text(&o.stdout)
}
#[test]
#[ignore = "boots real emulators; run with --ignored"]
fn end_to_end_build_run_exec_screenshot_test_cleanup() {
let e = Env::new("e2e");
let ctx = example_apks();
let ctx = ctx.to_str().unwrap();
let b = e.run(&["build", "-t", "e2e:test", ctx]);
assert_eq!(code(&b), 0, "{}", text(&b.stderr));
let id = text(&b.stdout).trim().to_string();
let again = e.run(&["build", "-t", "e2e:again", ctx]);
assert!(
text(&again.stderr).contains("Using cached image"),
"{}",
text(&again.stderr)
);
assert_eq!(text(&again.stdout).trim(), id);
assert!(Path::new(ctx).join("Dorkerfile.lock").is_file());
adb_ok(&e, &["run", "-d", "--name", "a", "e2e:test"]);
adb_ok(&e, &["run", "-d", "--name", "b", "e2e:test"]);
let ps: Vec<serde_json::Value> =
serde_json::from_slice(&e.run(&["ps", "--json"]).stdout).unwrap();
assert_eq!(ps.len(), 2);
assert_ne!(ps[0]["serial"], ps[1]["serial"]);
assert!(ps.iter().all(|r| r["status"] == "READY"));
assert_eq!(
adb_ok(&e, &["exec", "a", "getprop ro.build.version.sdk"]).trim(),
"35"
);
assert!(adb_ok(&e, &["exec", "a", "pm list packages"]).contains("com.dorker.sample"));
assert_eq!(code(&e.run(&["exec", "a", "sh -c 'exit 7'"])), 7);
adb_ok(&e, &["exec", "a", "touch /data/local/tmp/only_in_a"]);
assert!(!adb_ok(&e, &["exec", "b", "ls /data/local/tmp"]).contains("only_in_a"));
let png = e.home.join("shot.png");
adb_ok(&e, &["screenshot", "a", png.to_str().unwrap()]);
assert!(
std::fs::read(&png)
.unwrap()
.starts_with(b"\x89PNG\r\n\x1a\n")
);
let piped = e.run(&["screenshot", "a", "-"]);
assert_eq!(code(&piped), 0, "{}", text(&piped.stderr));
assert!(piped.stdout.starts_with(b"\x89PNG\r\n\x1a\n"));
assert_ne!(code(&e.run(&["screenshot", "a", "-", "--json"])), 0);
let runner = "com.dorker.sample.test/com.dorker.sample.SampleRunner";
let all = e.run(&["test", "a", "--runner", runner]);
assert_eq!(code(&all), 7);
assert!(text(&all.stdout).contains("2 passed, 1 failed"));
let ok = e.run(&[
"test",
"a",
"--runner",
runner,
"--class",
"com.dorker.sample.SampleTests#passing",
]);
assert_eq!(code(&ok), 0, "{}", text(&ok.stdout));
adb_ok(
&e,
&["run", "-d", "--name", "cold", "--memory", "3GB", "e2e:test"],
);
let first_shot = e.run(&["screenshot", "cold", "-"]);
assert_eq!(code(&first_shot), 0, "{}", text(&first_shot.stderr));
assert!(
first_shot.stdout.len() > 25_000,
"screenshot right after a cold boot must not be blank ({} bytes)",
first_shot.stdout.len()
);
assert!(adb_ok(&e, &["exec", "cold", "pm list packages"]).contains("com.dorker.sample"));
adb_ok(&e, &["exec", "cold", "echo kept > /data/local/tmp/marker"]);
adb_ok(&e, &["stop", "cold"]);
assert_eq!(
code(&e.run(&["start", "a"])),
8,
"starting a running instance is a conflict"
);
adb_ok(&e, &["start", "cold"]);
assert_eq!(
adb_ok(&e, &["exec", "cold", "cat /data/local/tmp/marker"]).trim(),
"kept"
);
assert_eq!(code(&e.run(&["run", "-d", "--name", "a", "e2e:test"])), 8);
assert_eq!(code(&e.run(&["rm", "a"])), 1, "running instance needs -f");
adb_ok(&e, &["rm", "-f", "a", "b", "cold"]);
assert_eq!(text(&e.run(&["ps", "-a", "--json"]).stdout).trim(), "[]");
assert_eq!(
std::fs::read_dir(e.home.join("instances")).unwrap().count(),
0
);
assert!(text(&e.run(&["images"]).stdout).contains("e2e"));
}
#[test]
#[ignore = "boots real emulators; run with --ignored"]
fn failed_build_leaves_nothing_behind() {
let e = Env::new("badbuild");
let ctx = e.home.join("ctx");
std::fs::create_dir_all(&ctx).unwrap();
write(
&ctx,
"Dorkerfile",
"FROM android:35\nINSTALL ./bad.apk\nSNAPSHOT ready\n",
);
write(&ctx, "bad.apk", "not an apk");
let o = e.run(&["build", "-t", "bad", ctx.to_str().unwrap()]);
assert_eq!(code(&o), 4, "{}", text(&o.stderr));
assert!(text(&o.stderr).contains("INSTALL_PARSE_FAILED"));
assert_eq!(text(&e.run(&["ps", "-a", "--json"]).stdout).trim(), "[]");
assert_eq!(
std::fs::read_dir(e.home.join("instances")).unwrap().count(),
0
);
let images: Vec<_> = std::fs::read_dir(e.home.join("images")).unwrap().collect();
assert!(images.is_empty(), "no partial image published");
assert!(!ctx.join("Dorkerfile.lock").exists());
}
struct McpClient {
child: std::process::Child,
reader: std::io::BufReader<std::process::ChildStdout>,
next_id: u64,
}
impl McpClient {
fn start(e: &Env) -> Self {
use std::process::Stdio;
let mut child = e
.cmd(&["mcp"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let reader = std::io::BufReader::new(child.stdout.take().unwrap());
McpClient {
child,
reader,
next_id: 0,
}
}
fn call(&mut self, method: &str, params: serde_json::Value) -> serde_json::Value {
use std::io::{BufRead, Write};
self.next_id += 1;
let id = self.next_id;
let msg =
serde_json::json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
writeln!(self.child.stdin.as_mut().unwrap(), "{msg}").unwrap();
loop {
let mut line = String::new();
assert!(
self.reader.read_line(&mut line).unwrap() > 0,
"server closed stdout"
);
let v: serde_json::Value =
serde_json::from_str(&line).expect("stdout carries only JSON-RPC");
if v["id"] == id {
return v;
}
}
}
fn tool(&mut self, name: &str, args: serde_json::Value) -> serde_json::Value {
self.call(
"tools/call",
serde_json::json!({"name": name, "arguments": args}),
)["result"]
.clone()
}
}
impl Drop for McpClient {
fn drop(&mut self) {
drop(self.child.stdin.take());
let _ = self.child.wait();
}
}
#[test]
#[ignore = "boots real emulators; run with --ignored"]
fn mcp_agent_workflow_against_a_real_emulator() {
use serde_json::json;
let e = Env::new("mcp-e2e");
let ctx = example_apks();
let mut c = McpClient::start(&e);
c.call("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "e2e", "version": "0"}}));
let built = c.tool(
"dorker_build",
json!({"context": ctx.to_str().unwrap(), "tag": "mcp:e2e"}),
);
assert_eq!(built["isError"], false, "{built}");
let run = c.tool("dorker_run", json!({"image": "mcp:e2e", "name": "agent"}));
assert_eq!(run["isError"], false, "{run}");
assert_eq!(run["structuredContent"]["status"], "READY");
assert_eq!(run["structuredContent"]["boot"], "snapshot");
let ok = c.tool(
"dorker_exec",
json!({"instance": "agent", "command": "getprop ro.build.version.sdk"}),
);
assert_eq!(
ok["structuredContent"]["stdout"].as_str().unwrap().trim(),
"35"
);
let bad = c.tool(
"dorker_exec",
json!({"instance": "agent", "command": "sh -c 'echo oops >&2; exit 3'"}),
);
assert_eq!(bad["isError"], false);
assert_eq!(bad["structuredContent"]["exit_code"], 3);
assert_eq!(
bad["structuredContent"]["stderr"].as_str().unwrap().trim(),
"oops"
);
let shot = c.tool("dorker_screenshot", json!({"instance": "agent"}));
let img = shot["content"]
.as_array()
.unwrap()
.iter()
.find(|b| b["type"] == "image")
.expect("image block");
assert_eq!(img["mimeType"], "image/png");
assert!(
img["data"].as_str().unwrap().starts_with("iVBORw0KGgo"),
"base64 of the PNG signature"
);
let runner = "com.dorker.sample.test/com.dorker.sample.SampleRunner";
let t = c.tool(
"dorker_test",
json!({"instance": "agent", "runner": runner}),
);
assert_eq!(t["structuredContent"]["ok"], false);
assert_eq!(t["structuredContent"]["passed"], 2);
assert_eq!(t["structuredContent"]["failures"][0]["test"], "failing");
let t = c.tool("dorker_test", json!({"instance": "agent", "runner": runner, "class": "com.dorker.sample.SampleTests#passing"}));
assert_eq!(t["structuredContent"]["ok"], true);
let dup = c.tool("dorker_run", json!({"image": "mcp:e2e", "name": "agent"}));
assert_eq!(dup["isError"], true);
assert!(
dup["content"][0]["text"]
.as_str()
.unwrap()
.contains("already in use")
);
let rm = c.tool("dorker_rm", json!({"instance": "agent"}));
assert_eq!(rm["structuredContent"]["removed"], json!(["agent"]));
assert_eq!(
c.tool("dorker_ps", json!({"all": true}))["structuredContent"]["instances"],
json!([])
);
}