Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
71 changes: 71 additions & 0 deletions crates/minimap-android/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@ pub struct TapPoint {
pub y: i64,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AdbDevice {
pub serial: String,
pub state: String,
}

pub fn parse_input_tap(output: &str) -> Result<TapPoint> {
let words: Vec<_> = output.split_whitespace().collect();
for window in words.windows(4) {
Expand Down Expand Up @@ -232,6 +238,21 @@ impl<R: CommandRunner> Adb<R> {
args
}

pub fn configured_serial(&self) -> Option<&str> {
self.serial.as_deref()
}

/// List all attached devices without applying the configured `-s` target.
/// The full list is needed to explain why a selected device is unavailable.
pub fn devices(&mut self) -> Result<Vec<AdbDevice>> {
let result = run_checked(
&mut self.runner,
vec![self.adb_bin.clone(), "devices".to_string()],
&[],
)?;
Ok(parse_adb_devices(&result.stdout))
}

pub fn tap(&mut self, point: TapPoint) -> Result<CommandResult> {
let mut args = self.base_args();
args.extend([
Expand Down Expand Up @@ -297,6 +318,23 @@ impl<R: CommandRunner> Adb<R> {
}
}

pub fn parse_adb_devices(stdout: &str) -> Vec<AdbDevice> {
stdout
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with("List of devices attached"))
.filter_map(|line| {
let mut fields = line.split_whitespace();
let serial = fields.next()?;
let state = fields.next()?;
Some(AdbDevice {
serial: serial.to_string(),
state: state.to_string(),
})
})
.collect()
}

/// Parse `adb shell wm size` output. When `Override size:` is present it wins
/// because that is what the device actually renders at; otherwise fall back to
/// `Physical size:`.
Expand Down Expand Up @@ -790,6 +828,39 @@ mod tests {
);
}

#[test]
fn parse_adb_devices_preserves_serial_and_state() {
let devices = parse_adb_devices(
"List of devices attached\nemulator-5554\tdevice\nphone-1\tunauthorized\n\n",
);
assert_eq!(
devices,
vec![
AdbDevice {
serial: "emulator-5554".to_string(),
state: "device".to_string(),
},
AdbDevice {
serial: "phone-1".to_string(),
state: "unauthorized".to_string(),
},
]
);
}

#[test]
fn adb_devices_does_not_apply_configured_serial() {
let mut runner = FakeRunner::new(vec![ok(
&["adb", "devices"],
"List of devices attached\nemulator-5554\tdevice\n",
)]);
{
let mut adb = Adb::new(&mut runner, Some("emulator-5554".to_string()));
assert_eq!(adb.devices().unwrap()[0].serial, "emulator-5554");
}
assert_eq!(runner.calls[0], vec!["adb", "devices"]);
}

#[test]
fn adb_inserts_serial_before_every_subcommand() {
let mut runner = FakeRunner::new(vec![
Expand Down
75 changes: 74 additions & 1 deletion crates/minimap-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,8 +269,9 @@ fn run(cli: Cli) -> Result<i32> {
let mut android = AndroidCli::new(SubprocessRunner, serial.clone());
let mut adb = Adb::new(SubprocessRunner, serial);
let result = layout_result(&root, &mut android, &mut adb, diff)?;
let code = exit_code_for_status(result["status"].as_str().unwrap_or("ok"));
print_json(&result);
Ok(0)
Ok(code)
}
}
}
Expand Down Expand Up @@ -595,6 +596,10 @@ fn layout_result<AR: CommandRunner, DR: CommandRunner>(
adb: &mut Adb<DR>,
diff: bool,
) -> Result<Value> {
if let Some(result) = layout_device_unavailable_result(adb)? {
return Ok(result);
}

if !diff {
if let Some(session) =
load_recent_session_place(root, adb, Duration::from_secs(LAYOUT_CACHE_TTL_SECS))?
Expand Down Expand Up @@ -678,6 +683,74 @@ fn layout_result<AR: CommandRunner, DR: CommandRunner>(
}))
}

fn layout_device_unavailable_result<R: CommandRunner>(adb: &mut Adb<R>) -> Result<Option<Value>> {
let devices = adb.devices()?;
let attempted_serial = adb.configured_serial().map(str::to_string);
let selected = attempted_serial
.as_ref()
.and_then(|serial| devices.iter().find(|device| device.serial == *serial));
let ready = match &attempted_serial {
Some(_) => selected.is_some_and(|device| device.state == "device"),
None => devices.iter().any(|device| device.state == "device"),
};
if ready {
return Ok(None);
}

let (code, summary, recovery) = if devices.is_empty() {
(
"no_device",
"no connected Android device is available".to_string(),
"Start an emulator or connect a device, then rerun `minimap layout`.".to_string(),
)
} else if let Some(serial) = &attempted_serial {
if selected.is_none() {
(
"device_not_found",
format!("selected Android device `{serial}` is not attached"),
format!("Start or connect `{serial}`, or choose an attached device with --serial."),
)
} else {
(
"device_not_ready",
format!("selected Android device `{serial}` is not ready"),
format!(
"Bring `{serial}` online and authorize debugging, then rerun `minimap layout`."
),
)
}
} else {
(
"no_ready_device",
"attached Android devices are not ready".to_string(),
"Bring one attached device online and authorize debugging, then rerun `minimap layout`."
.to_string(),
)
};

Ok(Some(json!({
"schema_version": RESULT_SCHEMA_VERSION,
"status": "device_unavailable",
"summary": summary,
"kind": "android_layout",
"layout": [],
"android_cli_notices": [],
"minimap": {"orientation": "unavailable"},
"error": {
"code": code,
"attempted_serial": attempted_serial,
"devices": devices,
"recovery": recovery
},
"metrics": {
"layout_calls_total": 0,
"layout_json_returned_to_agent": true
},
"changed_graph": false,
"changed_files": []
})))
}

fn tap_result<AR: CommandRunner, DR: CommandRunner>(
root: &Path,
android: &mut AndroidCli<AR>,
Expand Down
110 changes: 110 additions & 0 deletions crates/minimap-cli/tests/cli_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,78 @@ fn layout_normalizes_encoded_array_and_reports_android_notices() {
);
}

#[test]
fn layout_reports_no_device_then_succeeds_when_device_is_available() {
let temp = tempfile::tempdir().unwrap();
minimap(temp.path())
.args(["init", "--agents", "codex"])
.assert()
.success();
let bin = fake_bin(temp.path());
write_android_layout_script(&bin, &["home"]);
write_adb_script_no_devices(&bin);

let failure = minimap(temp.path())
.env("PATH", prepend_path(&bin))
.args(["layout"])
.assert()
.code(6);
let payload: Value = serde_json::from_slice(&failure.get_output().stdout).unwrap();
assert_eq!(payload["status"], "device_unavailable");
assert_eq!(payload["error"]["code"], "no_device");
assert_eq!(payload["error"]["attempted_serial"], Value::Null);
assert_eq!(payload["error"]["devices"], json!([]));
assert!(payload["error"]["recovery"]
.as_str()
.unwrap()
.contains("Start an emulator or connect a device"));
assert_eq!(payload["layout"], json!([]));
assert!(
!bin.join("android-count").exists(),
"no-device preflight must not invoke android layout"
);

write_adb_script(&bin);
let success = minimap(temp.path())
.env("PATH", prepend_path(&bin))
.args(["layout"])
.assert()
.success();
let payload: Value = serde_json::from_slice(&success.get_output().stdout).unwrap();
assert_eq!(payload["status"], "ok");
assert!(payload["layout"].is_array());
}

#[test]
fn layout_reports_attempted_serial_and_attached_devices() {
let temp = tempfile::tempdir().unwrap();
minimap(temp.path())
.args(["init", "--agents", "codex"])
.assert()
.success();
let bin = fake_bin(temp.path());
write_android_layout_script(&bin, &["home"]);
write_adb_script_with_other_device(&bin);

let failure = minimap(temp.path())
.env("PATH", prepend_path(&bin))
.args(["--serial", "emulator-5554", "layout"])
.assert()
.code(6);
let payload: Value = serde_json::from_slice(&failure.get_output().stdout).unwrap();
assert_eq!(payload["status"], "device_unavailable");
assert_eq!(payload["error"]["code"], "device_not_found");
assert_eq!(payload["error"]["attempted_serial"], "emulator-5554");
assert_eq!(
payload["error"]["devices"],
json!([{"serial": "emulator-5556", "state": "device"}])
);
assert!(
!bin.join("android-count").exists(),
"an unavailable selected serial must not invoke android layout"
);
}

#[test]
fn doctor_reports_healthy_environment() {
let temp = tempfile::tempdir().unwrap();
Expand Down Expand Up @@ -1720,12 +1792,46 @@ fn write_adb_script(bin: &Path) {
write_adb_script_with_size(bin, "1080x2400");
}

fn write_adb_script_no_devices(bin: &Path) {
write_executable(
&bin.join("adb"),
r#"#!/bin/sh
if [ "$1" = "devices" ]; then
printf 'List of devices attached\n\n'
exit 0
fi
if [ "$1" = "get-state" ] || [ "$1" = "get-serialno" ]; then
echo 'adb: no devices/emulators found' >&2
exit 1
fi
exit 2
"#,
);
}

fn write_adb_script_with_other_device(bin: &Path) {
write_executable(
&bin.join("adb"),
r#"#!/bin/sh
if [ "$1" = "devices" ]; then
printf 'List of devices attached\nemulator-5556\tdevice\n'
exit 0
fi
exit 2
"#,
);
}

/// Fake `adb` whose `wm size` reports a caller-chosen viewport. Used to record a
/// geometry edge at one viewport and replay it at another (see the viewport
/// mismatch test).
fn write_adb_script_with_size(bin: &Path, size: &str) {
let body = format!(
r#"#!/bin/sh
if [ "$1" = "devices" ]; then
printf 'List of devices attached\nfake-serial\tdevice\n'
exit 0
fi
if [ "$1" = "get-state" ]; then
printf 'device\n'
exit 0
Expand Down Expand Up @@ -1780,6 +1886,10 @@ exit 2
fn write_adb_script_expect_serial(bin: &Path, serial: &str) {
let body = format!(
r#"#!/bin/sh
if [ "$1" = "devices" ]; then
printf 'List of devices attached\n{serial}\tdevice\n'
exit 0
fi
if [ "$1" != "-s" ] || [ "$2" != "{serial}" ]; then
echo "expected -s {serial}, got: $*" >&2
exit 1
Expand Down
2 changes: 1 addition & 1 deletion crates/minimap-graph/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ pub fn exit_code_for_status(status: &str) -> i32 {
"needs_label" => 5,
"unknown" | "no_known_path" | "no_compatible_path" => 5,
"blocked_by_overlay" | "label_mismatch" | "action_failed" => 2,
"environment_error" => 6,
"environment_error" | "device_unavailable" => 6,
"config_error" => 7,
_ => 2,
}
Expand Down
Loading