From 7801380f92d047754db1628584d3bc3c0873fa79 Mon Sep 17 00:00:00 2001 From: DjDeveloperr Date: Sun, 31 May 2026 23:29:06 -0400 Subject: [PATCH] Add Android emulator startup args support --- README.md | 1 + .../run-android-comment-session/action.yml | 16 +- docs/api/rest.md | 14 +- docs/cli/commands.md | 5 + docs/cli/flags.md | 9 + docs/guide/github-actions.md | 31 ++-- packages/client/src/api/controls.test.ts | 31 ++++ packages/client/src/api/controls.ts | 7 +- packages/client/src/api/types.ts | 4 + packages/server/src/android.rs | 174 +++++++++++++++--- packages/server/src/api/routes.rs | 100 ++++++++-- packages/server/src/main.rs | 79 ++++++-- packages/simdeck-test/dist/index.d.ts | 5 +- packages/simdeck-test/dist/index.js | 9 +- packages/simdeck-test/src/index.ts | 21 ++- scripts/github-actions.test.mjs | 15 ++ skills/simdeck/SKILL.md | 1 + 17 files changed, 449 insertions(+), 73 deletions(-) create mode 100644 packages/client/src/api/controls.test.ts diff --git a/README.md b/README.md index 8b1a7942..c7a56903 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ simdeck --device describe --format agent --max-depth 2 simdeck list simdeck use simdeck boot +simdeck boot android: --android-emulator-arg=-no-snapshot simdeck shutdown simdeck erase simdeck install /path/to/App.app diff --git a/actions/run-android-comment-session/action.yml b/actions/run-android-comment-session/action.yml index e398374c..ae9a8f46 100644 --- a/actions/run-android-comment-session/action.yml +++ b/actions/run-android-comment-session/action.yml @@ -82,6 +82,10 @@ inputs: description: Android SDK build-tools version used to inspect APK metadata. required: false default: "36.0.0" + android_emulator_args: + description: Extra Android emulator startup arguments, one argument per line. + required: false + default: "" public_health_check: description: Verify the public Cloudflare Tunnel health endpoint before continuing. required: false @@ -123,6 +127,7 @@ runs: INPUT_ANDROID_TARGET_VALUE: ${{ inputs.android_target }} INPUT_ANDROID_ARCH_VALUE: ${{ inputs.android_arch }} INPUT_ANDROID_BUILD_TOOLS_VALUE: ${{ inputs.android_build_tools }} + INPUT_ANDROID_EMULATOR_ARGS_VALUE: ${{ inputs.android_emulator_args }} INPUT_PUBLIC_HEALTH_CHECK_VALUE: ${{ inputs.public_health_check }} INPUT_CI_PROXY_URL_VALUE: ${{ inputs.ci_proxy_url }} INPUT_PROXY_LINKS_VALUE: ${{ inputs.proxy_links }} @@ -163,6 +168,7 @@ runs: write_env "SIMDECK_ANDROID_TARGET" "${INPUT_ANDROID_TARGET_VALUE}" write_env "SIMDECK_ANDROID_ARCH" "${INPUT_ANDROID_ARCH_VALUE}" write_env "SIMDECK_ANDROID_BUILD_TOOLS" "${INPUT_ANDROID_BUILD_TOOLS_VALUE}" + write_env "SIMDECK_ANDROID_EMULATOR_ARGS" "${INPUT_ANDROID_EMULATOR_ARGS_VALUE}" write_env "SIMDECK_CI_PROXY_URL" "${INPUT_CI_PROXY_URL_VALUE}" write_env "SIMDECK_PROXY_LINKS" "${INPUT_PROXY_LINKS_VALUE}" write_env "SIMDECK_SESSION_PASSWORD" "${INPUT_SESSION_PASSWORD_VALUE}" @@ -656,7 +662,15 @@ runs: echo "ANDROID_UDID=${udid}" >> "${GITHUB_ENV}" echo "udid=${udid}" >> "${GITHUB_OUTPUT}" echo "Booting ${udid}" - simdeck --server-url "http://127.0.0.1:${SIMDECK_PORT}" boot "${udid}" + boot_args=() + if [[ -n "${SIMDECK_ANDROID_EMULATOR_ARGS:-}" ]]; then + while IFS= read -r arg; do + if [[ -n "${arg//[[:space:]]/}" ]]; then + boot_args+=(--android-emulator-arg="${arg}") + fi + done <<< "${SIMDECK_ANDROID_EMULATOR_ARGS}" + fi + simdeck --server-url "http://127.0.0.1:${SIMDECK_PORT}" boot "${udid}" "${boot_args[@]}" date +%s > /tmp/sim-boot-end - name: Update status comment with booted emulator URL diff --git a/docs/api/rest.md b/docs/api/rest.md index 5e5828fa..21bb6a45 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -70,6 +70,17 @@ Device IDs come from `/api/simulators`. Android IDs use the `android:` prefix. Booted devices are listed first. Paired iPhone and Apple Watch entries include `pairedWatchUDID` or `pairedPhoneUDID` when CoreSimulator reports a pairing. +Android emulator boot accepts optional startup arguments: + +```json +{ + "androidEmulatorArgs": ["-no-snapshot"] +} +``` + +SimDeck appends its own selected AVD and gRPC port flags around those arguments; +`-avd`, `@AVD`, and `-grpc` are reserved. + Create requests use identifiers from `/api/simulators/create-options`. New devices are booted before the response is returned. If an iOS simulator is created with `pairedWatch`, the watch is created, paired, and booted too. @@ -97,7 +108,8 @@ Android: "platform": "android", "name": "Pixel_8_API_36", "deviceTypeIdentifier": "pixel_8", - "runtimeIdentifier": "system-images;android-36;google_apis;arm64-v8a" + "runtimeIdentifier": "system-images;android-36;google_apis;arm64-v8a", + "androidEmulatorArgs": ["-no-snapshot"] } ``` diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 0b543610..352eadac 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -56,6 +56,7 @@ simdeck list simdeck list --format json simdeck use simdeck boot +simdeck boot android: --android-emulator-arg=-no-snapshot simdeck shutdown simdeck erase ``` @@ -68,6 +69,10 @@ inventory, including paths and display metadata. directory. After that, most device commands can omit ``; explicit UDIDs still override the default. +For Android emulator startup flags, repeat `--android-emulator-arg=` on +`simdeck boot`. SimDeck still owns the AVD selector and gRPC port used for the +browser stream. + ## Apps and URLs ```sh diff --git a/docs/cli/flags.md b/docs/cli/flags.md index 79741f39..c98d0845 100644 --- a/docs/cli/flags.md +++ b/docs/cli/flags.md @@ -53,6 +53,15 @@ Alias: `snapshot`. | `--point ,` | Describe the element at a screen point | | `--direct` | Skip service and use native accessibility directly | +## Device lifecycle + +| Command | Useful flags | +| ------- | ----------------------------------------------------------------------------------------------------------------------- | +| `boot` | `--android-emulator-arg=` for Android emulator startup flags; repeat once per argument, for example `-no-snapshot` | + +SimDeck reserves Android emulator target and stream flags such as `-avd` and +`-grpc` so browser streaming remains attached to the selected emulator. + ## Input | Command | Useful flags | diff --git a/docs/guide/github-actions.md b/docs/guide/github-actions.md index 46d2e372..516b02c3 100644 --- a/docs/guide/github-actions.md +++ b/docs/guide/github-actions.md @@ -188,21 +188,22 @@ Supported quality values include `tiny`, `low`, `economy`, `fast`, `smooth`, `ba ## Common inputs -| Input | Default | Purpose | -| ------------------- | ----------------------------------- | ------------------------------------------- | -| `bundle_id` | empty | Bundle ID to launch | -| `package_name` | empty | Android package name to launch | -| `build_workflow` | `build-ios-simulator.yml` | Workflow file that uploads the app artifact | -| `artifact_prefix` | `ios-simulator-app` / `android-apk` | Artifact prefix | -| `simdeck_version` | `latest` | npm version or dist-tag | -| `stream_profile` | `tiny` | Default stream quality | -| `simulator_name` | `iPhone 17 Pro` | Preferred simulator | -| `avd_name` | `SimDeck_Pixel_CI` | Preferred Android emulator | -| `keepalive_seconds` | `1800` | Session lifetime after launch | -| `simulator_cache` | `true` | Restore and save simulator cache | -| `proxy_links` | `true` | Post SimDeck CI proxy links | -| `ci_proxy_url` | `https://ci.simdeck.sh` | Optional SimDeck CI proxy URL | -| `session_password` | empty | Optional password for proxy-gated sessions | +| Input | Default | Purpose | +| ----------------------- | ----------------------------------- | ---------------------------------------------- | +| `bundle_id` | empty | Bundle ID to launch | +| `package_name` | empty | Android package name to launch | +| `build_workflow` | `build-ios-simulator.yml` | Workflow file that uploads the app artifact | +| `artifact_prefix` | `ios-simulator-app` / `android-apk` | Artifact prefix | +| `simdeck_version` | `latest` | npm version or dist-tag | +| `stream_profile` | `tiny` | Default stream quality | +| `simulator_name` | `iPhone 17 Pro` | Preferred simulator | +| `avd_name` | `SimDeck_Pixel_CI` | Preferred Android emulator | +| `android_emulator_args` | empty | Extra emulator startup arguments, one per line | +| `keepalive_seconds` | `1800` | Session lifetime after launch | +| `simulator_cache` | `true` | Restore and save simulator cache | +| `proxy_links` | `true` | Post SimDeck CI proxy links | +| `ci_proxy_url` | `https://ci.simdeck.sh` | Optional SimDeck CI proxy URL | +| `session_password` | empty | Optional password for proxy-gated sessions | ## Password-protected links diff --git a/packages/client/src/api/controls.test.ts b/packages/client/src/api/controls.test.ts new file mode 100644 index 00000000..427a9f45 --- /dev/null +++ b/packages/client/src/api/controls.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { bootSimulator } from "./controls"; + +describe("controls", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("posts Android emulator startup args when booting", async () => { + const fetchMock = vi.fn(async () => { + return new Response(JSON.stringify({ ok: true, simulator: null }), { + headers: { "content-type": "application/json" }, + status: 200, + }); + }); + vi.stubGlobal("fetch", fetchMock); + + await bootSimulator("android:Pixel_8_API_36", { + androidEmulatorArgs: ["-no-snapshot"], + }); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/simulators/android:Pixel_8_API_36/boot", + expect.objectContaining({ + body: JSON.stringify({ androidEmulatorArgs: ["-no-snapshot"] }), + method: "POST", + }), + ); + }); +}); diff --git a/packages/client/src/api/controls.ts b/packages/client/src/api/controls.ts index 6e04a7c3..439d8cfe 100644 --- a/packages/client/src/api/controls.ts +++ b/packages/client/src/api/controls.ts @@ -2,6 +2,7 @@ import { accessTokenFromLocation, apiHeaders, apiRequest } from "./client"; import { apiUrl } from "./config"; import type { ButtonPayload, + BootPayload, CrownPayload, EdgeTouchPayload, InstallUploadResponse, @@ -37,7 +38,7 @@ export interface ScreenRecordingStartResponse { async function postSimulatorAction( udid: string, action: string, - payload?: LaunchPayload | OpenUrlPayload, + payload?: BootPayload | LaunchPayload | OpenUrlPayload, ): Promise { if (action === "launch" || action === "open-url") { const response = await apiRequest<{ @@ -63,8 +64,8 @@ async function postSimulatorAction( return "simulator" in response ? response.simulator : null; } -export function bootSimulator(udid: string) { - return postSimulatorAction(udid, "boot"); +export function bootSimulator(udid: string, payload?: BootPayload) { + return postSimulatorAction(udid, "boot", payload); } export function shutdownSimulator(udid: string) { diff --git a/packages/client/src/api/types.ts b/packages/client/src/api/types.ts index 2c9b8f05..186930e5 100644 --- a/packages/client/src/api/types.ts +++ b/packages/client/src/api/types.ts @@ -211,6 +211,10 @@ export interface SimulatorResponse { simulator: SimulatorMetadata; } +export interface BootPayload { + androidEmulatorArgs?: string[]; +} + export interface InstallUploadResponse { action: "install"; fileName: string; diff --git a/packages/server/src/android.rs b/packages/server/src/android.rs index adc2c794..eef2f886 100644 --- a/packages/server/src/android.rs +++ b/packages/server/src/android.rs @@ -2,7 +2,7 @@ use crate::error::AppError; use bytes::BytesMut; use http::uri::PathAndQuery; use serde_json::{json, Value}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::env; use std::ffi::OsString; use std::io::{Read, Write}; @@ -85,6 +85,11 @@ pub struct AndroidEmulatorSpec { pub system_image_identifier: String, } +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AndroidBootOptions { + pub emulator_args: Vec, +} + #[derive(Debug)] pub struct AndroidFrame { pub width: u32, @@ -313,31 +318,18 @@ impl AndroidBridge { })) } - pub fn boot(&self, id: &str) -> Result { + pub fn boot_with_options( + &self, + id: &str, + options: &AndroidBootOptions, + ) -> Result { let avd_name = avd_from_id(id)?; if self.resolve_serial(&avd_name).is_ok() { return Ok(false); } let grpc_port = self.grpc_port_for_avd(&avd_name)?; - let grpc_port = grpc_port.to_string(); - let is_windows = cfg!(target_os = "windows"); - let window_mode = if is_windows { - "-qt-hide-window" - } else { - "-no-window" - }; - let mut args = vec![ - "-avd", - &avd_name, - window_mode, - "-no-audio", - "-gpu", - "swiftshader_indirect", - ]; - if is_windows { - args.extend(["-feature", "-Vulkan"]); - } - args.extend(["-grpc", &grpc_port]); + let args = + android_emulator_launch_args(&avd_name, grpc_port, options, std::env::consts::OS)?; Command::new(self.emulator_path()) .args(args) .stdin(Stdio::null()) @@ -1193,6 +1185,76 @@ impl AndroidBridge { } } +fn android_emulator_launch_args( + avd_name: &str, + grpc_port: u16, + options: &AndroidBootOptions, + os: &str, +) -> Result, AppError> { + let user_args = sanitized_android_emulator_args(&options.emulator_args)?; + let user_option_keys = user_args + .iter() + .filter_map(|arg| android_emulator_option_key(arg)) + .collect::>(); + + let mut args = vec!["-avd".to_owned(), avd_name.to_owned()]; + let window_mode = if os == "windows" { + "-qt-hide-window" + } else { + "-no-window" + }; + let window_is_configured = + user_option_keys.contains("-no-window") || user_option_keys.contains("-qt-hide-window"); + if !window_is_configured { + args.push(window_mode.to_owned()); + } + if !user_option_keys.contains("-no-audio") { + args.push("-no-audio".to_owned()); + } + if !user_option_keys.contains("-gpu") { + args.extend(["-gpu".to_owned(), "swiftshader_indirect".to_owned()]); + } + if os == "windows" && !user_option_keys.contains("-feature") { + args.extend(["-feature".to_owned(), "-Vulkan".to_owned()]); + } + args.extend(user_args); + args.extend(["-grpc".to_owned(), grpc_port.to_string()]); + Ok(args) +} + +fn sanitized_android_emulator_args(args: &[String]) -> Result, AppError> { + let mut output = Vec::new(); + for arg in args { + let trimmed = arg.trim(); + if trimmed.is_empty() { + return Err(AppError::bad_request( + "Android emulator startup args must not be empty.", + )); + } + if android_emulator_arg_is_simdeck_owned(trimmed) { + return Err(AppError::bad_request(format!( + "Android emulator startup arg `{trimmed}` is managed by SimDeck." + ))); + } + output.push(trimmed.to_owned()); + } + Ok(output) +} + +fn android_emulator_arg_is_simdeck_owned(arg: &str) -> bool { + if arg.starts_with('@') { + return true; + } + matches!(android_emulator_option_key(arg), Some("-avd" | "-grpc")) +} + +fn android_emulator_option_key(arg: &str) -> Option<&str> { + if !arg.starts_with('-') { + return None; + } + Some(arg.split_once('=').map(|(key, _)| key).unwrap_or(arg)) +} + impl AndroidGrpcFrameStream { pub async fn next_frame(&mut self) -> Result, AppError> { let Some(image) = self.inner.message().await.map_err(|error| { @@ -2302,6 +2364,76 @@ fn ensure_android_clipboard_available(output: &str) -> Result<(), AppError> { mod tests { use super::*; + #[test] + fn android_emulator_launch_args_include_user_startup_flags() { + let args = android_emulator_launch_args( + "Pixel_8_API_36", + 8554, + &AndroidBootOptions { + emulator_args: vec!["-no-snapshot".to_owned()], + }, + "linux", + ) + .unwrap(); + + assert_eq!( + args, + vec![ + "-avd", + "Pixel_8_API_36", + "-no-window", + "-no-audio", + "-gpu", + "swiftshader_indirect", + "-no-snapshot", + "-grpc", + "8554", + ] + ); + } + + #[test] + fn android_emulator_launch_args_let_user_replace_default_gpu() { + let args = android_emulator_launch_args( + "Pixel_8_API_36", + 8554, + &AndroidBootOptions { + emulator_args: vec!["-gpu".to_owned(), "host".to_owned()], + }, + "linux", + ) + .unwrap(); + + assert_eq!(args.iter().filter(|arg| *arg == "-gpu").count(), 1); + assert!(args.contains(&"host".to_owned())); + assert!(!args.contains(&"swiftshader_indirect".to_owned())); + } + + #[test] + fn android_emulator_launch_args_keep_simdeck_owned_flags_protected() { + let grpc_error = android_emulator_launch_args( + "Pixel_8_API_36", + 8554, + &AndroidBootOptions { + emulator_args: vec!["-grpc".to_owned(), "9000".to_owned()], + }, + "linux", + ) + .unwrap_err(); + assert!(grpc_error.to_string().contains("managed by SimDeck")); + + let avd_error = android_emulator_launch_args( + "Pixel_8_API_36", + 8554, + &AndroidBootOptions { + emulator_args: vec!["@Other_AVD".to_owned()], + }, + "linux", + ) + .unwrap_err(); + assert!(avd_error.to_string().contains("managed by SimDeck")); + } + #[test] fn android_nodes_keep_class_type_and_semantic_role() { let document = roxmltree::Document::parse( diff --git a/packages/server/src/api/routes.rs b/packages/server/src/api/routes.rs index 033628de..5e8275ed 100644 --- a/packages/server/src/api/routes.rs +++ b/packages/server/src/api/routes.rs @@ -1,5 +1,5 @@ use crate::accessibility::{interactive_accessibility_snapshot, AccessibilitySource}; -use crate::android::{self, AndroidBridge, AndroidEmulatorSpec}; +use crate::android::{self, AndroidBootOptions, AndroidBridge, AndroidEmulatorSpec}; use crate::api::json::json; use crate::auth; use crate::camera::{self, CameraStartRequest, CameraSwitchRequest}; @@ -509,6 +509,13 @@ struct CreateSimulatorPayload { device_type_identifier: String, runtime_identifier: Option, paired_watch: Option, + android_emulator_args: Option>, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BootSimulatorPayload { + android_emulator_args: Option>, } #[derive(Clone, Debug, Deserialize)] @@ -1749,6 +1756,7 @@ async fn create_simulator( } let runtime_identifier = trimmed_optional_string(payload.runtime_identifier); + let android_emulator_args = payload.android_emulator_args.unwrap_or_default(); if platform.eq_ignore_ascii_case("android") { let system_image_identifier = runtime_identifier.ok_or_else(|| { AppError::bad_request("Android emulator creation requires `runtimeIdentifier`.") @@ -1770,7 +1778,14 @@ async fn create_simulator( .and_then(Value::as_str) .ok_or_else(|| AppError::internal("Android create did not return an emulator ID."))? .to_owned(); - boot_android_device(state.clone(), udid.clone()).await?; + boot_android_device( + state.clone(), + udid.clone(), + AndroidBootOptions { + emulator_args: android_emulator_args, + }, + ) + .await?; let devices = all_device_values(state, true).await?; let simulator = devices .iter() @@ -1786,6 +1801,11 @@ async fn create_simulator( "pairedWatchSimulator": null, }))); } + if !android_emulator_args.is_empty() { + return Err(AppError::bad_request( + "`androidEmulatorArgs` only apply to Android emulator creation.", + )); + } let paired_watch = payload .paired_watch @@ -2129,9 +2149,13 @@ fn performance_log_entry_matches( .any(|needle| haystack.contains(needle)) } -async fn boot_android_device(state: AppState, udid: String) -> Result<(), AppError> { +async fn boot_android_device( + state: AppState, + udid: String, + options: AndroidBootOptions, +) -> Result<(), AppError> { run_android_action(state, move |android| { - android.boot(&udid)?; + android.boot_with_options(&udid, &options)?; android.wait_until_booted(&udid, Duration::from_secs(240))?; Ok(()) }) @@ -2153,15 +2177,43 @@ async fn boot_ios_device(state: AppState, udid: String) -> Result<(), AppError> async fn boot_simulator( State(state): State, Path(udid): Path, + body: Bytes, ) -> Result, AppError> { + let payload = boot_simulator_payload_from_body(&body)?; + let options = android_boot_options(payload.android_emulator_args); if android::is_android_id(&udid) { - boot_android_device(state.clone(), udid.clone()).await?; + boot_android_device(state.clone(), udid.clone(), options).await?; return android_simulator_payload(state, udid).await; } + if !options.emulator_args.is_empty() { + return Err(AppError::bad_request( + "`androidEmulatorArgs` only apply to Android emulator IDs.", + )); + } boot_ios_device(state.clone(), udid.clone()).await?; simulator_payload(state, udid).await } +fn boot_simulator_payload_from_body(body: &Bytes) -> Result { + if body.is_empty() || body.iter().all(u8::is_ascii_whitespace) { + return Ok(BootSimulatorPayload::default()); + } + if body + .strip_prefix(b"null") + .is_some_and(|rest| rest.iter().all(u8::is_ascii_whitespace)) + { + return Ok(BootSimulatorPayload::default()); + } + serde_json::from_slice::(body) + .map_err(|error| AppError::bad_request(format!("Invalid boot request body: {error}"))) +} + +fn android_boot_options(android_emulator_args: Option>) -> AndroidBootOptions { + AndroidBootOptions { + emulator_args: android_emulator_args.unwrap_or_default(), + } +} + async fn shutdown_simulator( State(state): State, Path(udid): Path, @@ -6036,15 +6088,16 @@ async fn accessibility_snapshot_with_options( #[cfg(test)] mod tests { use super::{ - accessibility_point_snapshot, attach_tree_metadata, available_sources_for_snapshot, - available_sources_with_native_ax, best_inspector_session, - chrome_devtools_source_for_session, client_stats_foreground, - compact_accessibility_snapshot, element_matches_selector, first_matching_element, - inspector_available_sources, inspector_metadata, inspector_session_from_published, - inspector_session_score, is_inspector_agent_transport_path, - is_transient_native_ax_snapshot_error, logical_screen_size_from_display_pixels, - normalize_inspector_node, normalize_screen_point_from_snapshot, - normalized_gesture_coordinates, parse_lsof_tcp_listener, parse_ui_application_service_line, + accessibility_point_snapshot, android_boot_options, attach_tree_metadata, + available_sources_for_snapshot, available_sources_with_native_ax, best_inspector_session, + boot_simulator_payload_from_body, chrome_devtools_source_for_session, + client_stats_foreground, compact_accessibility_snapshot, element_matches_selector, + first_matching_element, inspector_available_sources, inspector_metadata, + inspector_session_from_published, inspector_session_score, + is_inspector_agent_transport_path, is_transient_native_ax_snapshot_error, + logical_screen_size_from_display_pixels, normalize_inspector_node, + normalize_screen_point_from_snapshot, normalized_gesture_coordinates, + parse_lsof_tcp_listener, parse_ui_application_service_line, process_identifier_from_accessibility_snapshot, resolved_stream_quality_limits, scroll_input_plan_for_udid, split_filter_values, stream_quality_profile, suppress_native_ax_translation_error, tap_point_from_snapshot, trim_tree_depth, @@ -6077,6 +6130,25 @@ mod tests { } } + #[test] + fn boot_simulator_payload_reads_android_emulator_args() { + let payload = boot_simulator_payload_from_body(&Bytes::from_static( + br#"{"androidEmulatorArgs":["-no-snapshot","-gpu","host"]}"#, + )) + .unwrap(); + let options = android_boot_options(payload.android_emulator_args); + + assert_eq!(options.emulator_args, vec!["-no-snapshot", "-gpu", "host"]); + } + + #[test] + fn boot_simulator_payload_allows_legacy_null_body() { + let payload = boot_simulator_payload_from_body(&Bytes::from_static(b"null")).unwrap(); + let options = android_boot_options(payload.android_emulator_args); + + assert!(options.emulator_args.is_empty()); + } + fn accessibility_snapshot() -> Value { json!({ "roots": [{ diff --git a/packages/server/src/main.rs b/packages/server/src/main.rs index 0a8425bf..b34cc9f9 100644 --- a/packages/server/src/main.rs +++ b/packages/server/src/main.rs @@ -230,6 +230,12 @@ enum Command { }, Boot { udid: Option, + #[arg( + long = "android-emulator-arg", + value_name = "ARG", + allow_hyphen_values = true + )] + android_emulator_args: Vec, }, Shutdown { udid: Option, @@ -2587,6 +2593,14 @@ fn removed_service_process_name() -> String { ['d', 'a', 'e', 'm', 'o', 'n'].into_iter().collect() } +fn android_boot_request_body(android_emulator_args: &[String]) -> Value { + if android_emulator_args.is_empty() { + Value::Null + } else { + serde_json::json!({ "androidEmulatorArgs": android_emulator_args }) + } +} + fn run_no_command_action(action: NoCommandAction) -> anyhow::Result<()> { match action { NoCommandAction::Service(options) => run_default_service(options), @@ -3815,15 +3829,22 @@ fn main() -> anyhow::Result<()> { }))?; Ok(()) } - Command::Boot { udid } => { + Command::Boot { + udid, + android_emulator_args, + } => { let udid = resolve_device_udid(udid.as_deref())?; let service_url = command_service_url(explicit_server_url.as_deref())?; - service_post_ok(&service_url, &udid, "boot", &Value::Null)?; + let request_body = android_boot_request_body(&android_emulator_args); + service_post_ok(&service_url, &udid, "boot", &request_body)?; println!( "{}", - serde_json::to_string_pretty( - &serde_json::json!({ "ok": true, "udid": udid, "action": "boot" }) - )? + serde_json::to_string_pretty(&serde_json::json!({ + "ok": true, + "udid": udid, + "action": "boot", + "androidEmulatorArgs": android_emulator_args, + }))? ); Ok(()) } @@ -6119,11 +6140,12 @@ fn default_client_root() -> anyhow::Result { #[cfg(test)] mod tests { use super::{ - batch_line_to_json_step, http_url_for_host, interactive_accessibility_snapshot, - is_tailscale_ip, maestro_commands_from_flow, maestro_selector, - no_command_action_from_args_slice, normalize_accessibility_point_for_display, - parse_maestro_flow_yaml, parse_maestro_point, parse_optional_udid_f64_args, - parse_optional_udid_text_args, parse_optional_udid_value_args, parse_tap_command_args, + android_boot_request_body, batch_line_to_json_step, http_url_for_host, + interactive_accessibility_snapshot, is_tailscale_ip, maestro_commands_from_flow, + maestro_selector, no_command_action_from_args_slice, + normalize_accessibility_point_for_display, parse_maestro_flow_yaml, parse_maestro_point, + parse_optional_udid_f64_args, parse_optional_udid_text_args, + parse_optional_udid_value_args, parse_tap_command_args, parse_workspace_service_process_line, removed_service_process_name, render_agent_accessibility_tree, render_qr_code, run_maestro_command, server_health_watchdog_should_restart, service_addresses, service_matches_launch_options, @@ -6918,10 +6940,15 @@ mod tests { #[test] fn device_commands_accept_omitted_udid() { let parsed = Cli::try_parse_from(["simdeck", "boot"]).unwrap(); - let Command::Boot { udid } = parsed.command else { + let Command::Boot { + udid, + android_emulator_args, + } = parsed.command + else { panic!("expected boot command"); }; assert_eq!(udid, None); + assert!(android_emulator_args.is_empty()); let parsed = Cli::try_parse_from(["simdeck", "home"]).unwrap(); let Command::Home { udid } = parsed.command else { @@ -6937,6 +6964,36 @@ mod tests { assert!(stdout); } + #[test] + fn boot_command_accepts_android_emulator_startup_args() { + let parsed = Cli::try_parse_from([ + "simdeck", + "boot", + "android:Pixel_8_API_36", + "--android-emulator-arg=-no-snapshot", + "--android-emulator-arg=-gpu", + "--android-emulator-arg=host", + ]) + .unwrap(); + + let Command::Boot { + udid, + android_emulator_args, + } = parsed.command + else { + panic!("expected boot command"); + }; + + assert_eq!(udid.as_deref(), Some("android:Pixel_8_API_36")); + assert_eq!(android_emulator_args, vec!["-no-snapshot", "-gpu", "host"]); + assert_eq!( + android_boot_request_body(&android_emulator_args), + serde_json::json!({ + "androidEmulatorArgs": ["-no-snapshot", "-gpu", "host"] + }) + ); + } + #[test] fn payload_commands_keep_positional_udid_but_allow_default_device() { let parsed = Cli::try_parse_from(["simdeck", "launch", "com.example.App"]).unwrap(); diff --git a/packages/simdeck-test/dist/index.d.ts b/packages/simdeck-test/dist/index.d.ts index 5909c627..65629a6e 100644 --- a/packages/simdeck-test/dist/index.d.ts +++ b/packages/simdeck-test/dist/index.d.ts @@ -68,6 +68,9 @@ export type ScreenshotOptions = { export type ScreenRecordingOptions = { seconds?: number; }; +export type AndroidBootOptions = { + androidEmulatorArgs?: string[]; +}; type DeviceMethod = { (udid: string, ...args: TArgs): TResult; (...args: TArgs): TResult; @@ -79,7 +82,7 @@ export type SimDeckSession = { udid?: string; device(udid: string): SimDeckSession; list(): Promise; - boot: DeviceMethod<[], Promise>; + boot: DeviceMethod<[options?: AndroidBootOptions], Promise>; shutdown: DeviceMethod<[], Promise>; erase: DeviceMethod<[], Promise>; install: DeviceMethod<[appPath: string], Promise>; diff --git a/packages/simdeck-test/dist/index.js b/packages/simdeck-test/dist/index.js index 6e883c53..2c822003 100644 --- a/packages/simdeck-test/dist/index.js +++ b/packages/simdeck-test/dist/index.js @@ -61,8 +61,8 @@ export async function connect(options = {}) { device: (udid) => createSession(udid), list: () => requestJson(endpoint, "GET", "/api/simulators"), boot: (...args) => { - const { udid } = resolveNoArgDeviceCall(args); - return requestJson(endpoint, "POST", simulatorPath(udid, "/boot"), null); + const { udid, options } = resolveOptionalObjectDeviceCall(args); + return requestJson(endpoint, "POST", simulatorPath(udid, "/boot"), androidBootRequestBody(options)); }, shutdown: (...args) => { const { udid } = resolveNoArgDeviceCall(args); @@ -526,6 +526,11 @@ function runJson(command, args, options = {}) { function requestOk(endpoint, pathName, body) { return requestJson(endpoint, "POST", pathName, body).then(() => undefined); } +function androidBootRequestBody(options) { + return options?.androidEmulatorArgs?.length + ? { androidEmulatorArgs: options.androidEmulatorArgs } + : null; +} function requestJson(endpoint, method, pathName, body) { return requestBuffer(endpoint, pathName, method, body).then((buffer) => JSON.parse(buffer.toString("utf8"))); } diff --git a/packages/simdeck-test/src/index.ts b/packages/simdeck-test/src/index.ts index 79aa8b08..09065792 100644 --- a/packages/simdeck-test/src/index.ts +++ b/packages/simdeck-test/src/index.ts @@ -98,6 +98,10 @@ export type ScreenRecordingOptions = { seconds?: number; }; +export type AndroidBootOptions = { + androidEmulatorArgs?: string[]; +}; + type DeviceMethod = { (udid: string, ...args: TArgs): TResult; (...args: TArgs): TResult; @@ -110,7 +114,7 @@ export type SimDeckSession = { udid?: string; device(udid: string): SimDeckSession; list(): Promise; - boot: DeviceMethod<[], Promise>; + boot: DeviceMethod<[options?: AndroidBootOptions], Promise>; shutdown: DeviceMethod<[], Promise>; erase: DeviceMethod<[], Promise>; install: DeviceMethod<[appPath: string], Promise>; @@ -283,13 +287,16 @@ export async function connect( udid: defaultUdid, device: (udid: string) => createSession(udid), list: () => requestJson(endpoint, "GET", "/api/simulators"), - boot: (...args: [] | [string]) => { - const { udid } = resolveNoArgDeviceCall(args); + boot: ( + ...args: [string, AndroidBootOptions?] | [AndroidBootOptions?] + ) => { + const { udid, options } = + resolveOptionalObjectDeviceCall(args); return requestJson( endpoint, "POST", simulatorPath(udid, "/boot"), - null, + androidBootRequestBody(options), ); }, shutdown: (...args: [] | [string]) => { @@ -903,6 +910,12 @@ function requestOk( return requestJson(endpoint, "POST", pathName, body).then(() => undefined); } +function androidBootRequestBody(options?: AndroidBootOptions): unknown { + return options?.androidEmulatorArgs?.length + ? { androidEmulatorArgs: options.androidEmulatorArgs } + : null; +} + function requestJson( endpoint: string, method: string, diff --git a/scripts/github-actions.test.mjs b/scripts/github-actions.test.mjs index 0696ef9e..fe10f1d7 100644 --- a/scripts/github-actions.test.mjs +++ b/scripts/github-actions.test.mjs @@ -123,6 +123,21 @@ test("Android integration runner resolves Windows executables", () => { assert.match(androidIntegration, /\.exe/); }); +test("Android PR comment forwards configured emulator startup args", () => { + assert.match(androidAction, /android_emulator_args:/); + assert.match(androidAction, /INPUT_ANDROID_EMULATOR_ARGS_VALUE/); + + const bootStep = stepSlice( + androidAction, + "Boot Android emulator", + "Update status comment with booted emulator URL", + ); + + assert.match(bootStep, /SIMDECK_ANDROID_EMULATOR_ARGS/); + assert.match(bootStep, /--android-emulator-arg=/); + assert.match(bootStep, /simdeck --server-url .* boot "\$\{udid\}"/); +}); + test("iOS PR comment waits for public simulator list access", () => { const prebootIndex = iosAction.indexOf( "- name: Select and preboot simulator", diff --git a/skills/simdeck/SKILL.md b/skills/simdeck/SKILL.md index fda1b65d..555ae188 100644 --- a/skills/simdeck/SKILL.md +++ b/skills/simdeck/SKILL.md @@ -71,6 +71,7 @@ simdeck list simdeck list --format json simdeck use simdeck boot +simdeck boot android: --android-emulator-arg=-no-snapshot simdeck shutdown simdeck erase simdeck core-simulator restart