use serial_test::serial;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::process::Stdio;
fn bmux_binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_bmux"))
}
fn fixtures_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/playbooks")
}
const ATTACH_SIM_FIXTURES: &[&str] = &[
"attach_sim_tab_drag.dsl",
"attach_sim_tab_drag_no_motion.dsl",
"attach_sim_tab_drag_left_half.dsl",
"attach_sim_tab_drag_right_half.dsl",
"attach_sim_tab_drag_gap.dsl",
"attach_sim_tab_drag_mru_noop.dsl",
"attach_sim_tab_click_switch.dsl",
"attach_sim_status_top.dsl",
"attach_sim_status_snapshot.dsl",
"attach_sim_scrollback_selection.dsl",
"attach_sim_pane_resize.dsl",
"attach_sim_floating_pane_drag.dsl",
"attach_sim_prompt_overlay.dsl",
"attach_sim_help_overlay_scroll.dsl",
];
struct TempDirGuard {
path: PathBuf,
}
impl TempDirGuard {
fn new(label: &str) -> Self {
let path = std::env::temp_dir().join(format!(
"bmux-playbook-integration-{label}-{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&path).expect("create temp dir");
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempDirGuard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
struct BmuxCommandSandbox {
root: TempDirGuard,
}
impl BmuxCommandSandbox {
fn new(label: &str) -> Self {
let root = TempDirGuard::new(label);
for dir in ["config", "runtime", "data", "state", "logs"] {
std::fs::create_dir_all(root.path().join(dir)).expect("create sandbox dir");
}
Self { root }
}
fn command(&self) -> Command {
let mut command = Command::new(bmux_binary());
self.apply(&mut command);
command
}
fn apply(&self, command: &mut Command) {
command
.env("BMUX_CONFIG_DIR", self.root.path().join("config"))
.env("BMUX_RUNTIME_DIR", self.root.path().join("runtime"))
.env("BMUX_DATA_DIR", self.root.path().join("data"))
.env("BMUX_STATE_DIR", self.root.path().join("state"))
.env("BMUX_LOG_DIR", self.root.path().join("logs"));
}
}
fn endpoint_from_socket_path(path: &str) -> bmux_ipc::IpcEndpoint {
if cfg!(windows) {
bmux_ipc::IpcEndpoint::windows_named_pipe(path.to_string())
} else {
bmux_ipc::IpcEndpoint::unix_socket(path)
}
}
async fn send_json_line(
writer: &mut (impl tokio::io::AsyncWrite + Unpin),
payload: &serde_json::Value,
) {
use tokio::io::AsyncWriteExt;
writer
.write_all(format!("{}\n", payload).as_bytes())
.await
.expect("failed to write json command");
writer.flush().await.expect("failed to flush");
}
async fn read_json_line(reader: &mut (impl tokio::io::AsyncBufRead + Unpin)) -> serde_json::Value {
use tokio::io::AsyncBufReadExt;
let mut line = String::new();
let read = reader
.read_line(&mut line)
.await
.expect("failed to read response line");
assert!(read > 0, "unexpected EOF while waiting for response line");
serde_json::from_str(line.trim())
.unwrap_or_else(|e| panic!("failed to parse json line: {e}\nline: {line}"))
}
async fn read_until_json(
reader: &mut (impl tokio::io::AsyncBufRead + Unpin),
max_messages: usize,
context: &str,
mut predicate: impl FnMut(&serde_json::Value) -> bool,
) -> serde_json::Value {
let mut last_message = None;
for _ in 0..max_messages {
let msg = read_json_line(reader).await;
if predicate(&msg) {
return msg;
}
last_message = Some(msg);
}
panic!(
"did not observe expected message for {context} within {max_messages} messages; last={:#}",
last_message.unwrap_or(serde_json::json!({"status":"none"}))
);
}
async fn read_response_for_request_id(
reader: &mut (impl tokio::io::AsyncBufRead + Unpin),
request_id: &str,
) -> serde_json::Value {
read_until_json(reader, 80, request_id, |msg| {
msg["request_id"] == request_id && (msg["type"] == "response" || msg["type"] == "error")
})
.await
}
fn run_playbook_fixture(name: &str) -> (serde_json::Value, bool) {
let fixture = fixtures_dir().join(name);
assert!(fixture.exists(), "fixture not found: {}", fixture.display());
let sandbox = BmuxCommandSandbox::new("run-playbook-fixture");
let output = sandbox
.command()
.args(["playbook", "run", "--json", fixture.to_str().unwrap()])
.env("BMUX_PLAYBOOK_ENV_MODE", "inherit")
.output()
.expect("failed to run bmux playbook");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
panic!(
"failed to parse JSON output for {name}:\n error: {e}\n stdout: {stdout}\n stderr: {stderr}\n exit: {:?}",
output.status
)
});
let pass = json["pass"].as_bool().unwrap_or(false);
(json, pass)
}
#[test]
#[serial]
fn playbook_run_interactive_step_controls() {
use std::io::Write;
let fixture = fixtures_dir().join("echo_hello.dsl");
assert!(fixture.exists(), "fixture not found: {}", fixture.display());
let sandbox = BmuxCommandSandbox::new("interactive-step-controls");
let mut child = sandbox
.command()
.args([
"playbook",
"run",
"--json",
"--interactive",
fixture.to_str().expect("fixture path should be utf-8"),
])
.env("BMUX_PLAYBOOK_ENV_MODE", "inherit")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn interactive playbook run");
{
let mut stdin = child.stdin.take().expect("stdin should be piped");
stdin
.write_all(
b"n\n: send-keys keys='echo adhoc_interactive_marker\\r'\n: wait-for pattern='adhoc_interactive_marker'\ns\nc\n",
)
.expect("failed writing interactive commands");
}
let output = child
.wait_with_output()
.expect("failed waiting for interactive run");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
panic!(
"failed to parse JSON output:\n error: {e}\n stdout: {stdout}\n stderr: {stderr}\n exit: {:?}",
output.status
)
});
assert!(
json["pass"].as_bool().unwrap_or(false),
"interactive run should pass: {json:#}"
);
let steps = json["steps"].as_array().expect("steps should be array");
assert!(
steps.iter().all(|step| step["status"] == "pass"),
"all scheduled steps should pass: {json:#}"
);
assert!(
stderr.contains("interactive playbook controls"),
"stderr should include controls help: {stderr}"
);
assert!(
stderr.contains("adhoc_interactive_marker"),
"screen output should include ad-hoc marker: {stderr}"
);
}
#[test]
fn playbook_echo_hello() {
let (json, pass) = run_playbook_fixture("echo_hello.dsl");
assert!(pass, "playbook should pass: {json:#}");
let steps = json["steps"].as_array().expect("steps should be array");
assert!(
steps.iter().all(|s| s["status"] == "pass"),
"all steps should pass: {json:#}"
);
}
#[test]
fn playbook_echo_assert() {
let (json, pass) = run_playbook_fixture("echo_assert.dsl");
assert!(pass, "playbook should pass: {json:#}");
let steps = json["steps"].as_array().expect("steps should be array");
let assert_steps: Vec<_> = steps
.iter()
.filter(|s| s["action"] == "assert-screen")
.collect();
assert!(
assert_steps.len() >= 2,
"should have at least 2 assert-screen steps: {json:#}"
);
for step in &assert_steps {
assert_eq!(
step["status"], "pass",
"assert-screen should pass: {step:#}"
);
}
}
#[test]
fn playbook_multi_pane() {
let (json, pass) = run_playbook_fixture("multi_pane.dsl");
assert!(pass, "multi-pane playbook should pass: {json:#}");
let steps = json["steps"].as_array().expect("steps should be array");
let assert_steps: Vec<_> = steps
.iter()
.filter(|s| s["action"] == "assert-screen")
.collect();
assert!(
assert_steps.len() >= 2,
"should have assert-screen steps for both panes: {json:#}"
);
for step in &assert_steps {
assert_eq!(step["status"], "pass", "assert should pass: {step:#}");
}
}
#[test]
fn playbook_wait_for_regex() {
let (json, pass) = run_playbook_fixture("wait_for_regex.dsl");
assert!(pass, "wait-for regex should pass: {json:#}");
}
#[test]
fn playbook_attach_scrollback() {
let (json, pass) = run_playbook_fixture("attach_scrollback.dsl");
assert!(
pass,
"attach scrollback playbook should pass without leaking literal keys: {json:#}"
);
}
#[test]
fn playbook_alt_screen_exit_cursor() {
let (json, pass) = run_playbook_fixture("alt_screen_exit_cursor.dsl");
assert!(
pass,
"alt-screen enter/exit cursor restoration playbook should pass: {json:#}"
);
}
#[test]
fn playbook_synthetic_alt_sigint_reentry_restores_cursor() {
let (json, pass) = run_playbook_fixture("synthetic_alt_sigint_reentry.dsl");
assert!(
pass,
"synthetic SIGINT alt-screen reentry should restore cursor to pre-alt anchor: {json:#}"
);
let steps = json["steps"].as_array().expect("steps should be array");
let cursor_step = steps
.iter()
.find(|step| step["action"] == "assert-cursor")
.expect("expected assert-cursor step in synthetic fixture");
assert_eq!(
cursor_step["status"], "pass",
"cursor assertion should pass once reentry is correct: {json:#}"
);
}
#[test]
fn playbook_synthetic_alt_split_exit_reentry_restores_cursor() {
let (json, pass) = run_playbook_fixture("synthetic_alt_split_exit_reentry.dsl");
assert!(
pass,
"synthetic split 1049 exit sequence should restore cursor to pre-alt anchor: {json:#}"
);
let steps = json["steps"].as_array().expect("steps should be array");
let cursor_step = steps
.iter()
.find(|step| step["action"] == "assert-cursor")
.expect("expected assert-cursor step in synthetic fixture");
assert_eq!(
cursor_step["status"], "pass",
"cursor assertion should pass once split-sequence parsing is correct: {json:#}"
);
}
#[test]
fn playbook_send_keys_fails_during_attach_scrollback() {
let (json, pass) = run_playbook_fixture("send_keys_in_scrollback.dsl");
assert!(
!pass,
"send-keys should fail when attach scrollback is active: {json:#}"
);
let error = json["error"].as_str().unwrap_or("");
assert!(
error.contains("send-keys targets pane input while attach scrollback is active"),
"error should explain send-keys/send-attach split: {json:#}"
);
}
#[test]
fn playbook_env_mode_clean() {
let fixture = fixtures_dir().join("env_mode_clean.dsl");
let sandbox = BmuxCommandSandbox::new("env-mode-clean");
let output = sandbox
.command()
.args(["playbook", "run", "--json", fixture.to_str().unwrap()])
.output()
.expect("failed to run bmux playbook");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let json: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("JSON parse failed: {e}\nstdout: {stdout}\nstderr: {stderr}"));
let pass = json["pass"].as_bool().unwrap_or(false);
assert!(
pass,
"env_mode_clean playbook should pass (TERM should be xterm-256color): {json:#}"
);
}
#[test]
fn playbook_screen_status() {
let (json, pass) = run_playbook_fixture("screen_status.dsl");
assert!(pass, "screen/status playbook should pass: {json:#}");
let steps = json["steps"].as_array().expect("steps should be array");
let screen_step = steps.iter().find(|s| s["action"] == "screen");
assert!(screen_step.is_some(), "should have a screen step: {json:#}");
let detail = screen_step.unwrap()["detail"].as_str().unwrap_or("");
assert!(
!detail.is_empty(),
"screen step should have detail: {json:#}"
);
let status_step = steps.iter().find(|s| s["action"] == "status");
assert!(status_step.is_some(), "should have a status step: {json:#}");
let status_detail = status_step.unwrap()["detail"].as_str().unwrap_or("");
assert!(
status_detail.contains("session_id="),
"status should contain session_id: {status_detail}"
);
}
#[test]
fn playbook_snapshot_capture() {
let (json, pass) = run_playbook_fixture("snapshot_capture.dsl");
assert!(pass, "snapshot playbook should pass: {json:#}");
let snapshots = json["snapshots"].as_array();
assert!(
snapshots.is_some() && !snapshots.unwrap().is_empty(),
"should have snapshots: {json:#}"
);
let snap = &snapshots.unwrap()[0];
assert_eq!(snap["id"], "after_echo", "snapshot id: {snap:#}");
let panes = snap["panes"]
.as_array()
.expect("snapshot should have panes");
assert!(!panes.is_empty(), "snapshot should have pane captures");
let pane_text = panes[0]["screen_text"].as_str().unwrap_or("");
assert!(
pane_text.contains("snap_content_marker"),
"snapshot pane text should contain marker: {pane_text}"
);
}
#[test]
fn playbook_failing_assert() {
let (json, pass) = run_playbook_fixture("failing_assert.dsl");
assert!(!pass, "failing playbook should not pass: {json:#}");
let steps = json["steps"].as_array().expect("steps should be array");
let failed_step = steps
.iter()
.find(|s| s["action"] == "assert-screen" && s["status"] == "fail");
assert!(
failed_step.is_some(),
"should have a failed assert-screen step: {json:#}"
);
let failed = failed_step.unwrap();
assert!(
failed.get("expected").is_some() && !failed["expected"].is_null(),
"failed step should have 'expected' field: {failed:#}"
);
assert_eq!(
failed["expected"].as_str().unwrap(),
"nonexistent_string_xyz",
"expected should be the contains pattern"
);
assert!(
failed.get("actual").is_some() && !failed["actual"].is_null(),
"failed step should have 'actual' field: {failed:#}"
);
assert!(
failed["actual"].as_str().unwrap().contains("real_output"),
"actual should contain the real screen text: {failed:#}"
);
let captures = failed["failure_captures"].as_array();
assert!(
captures.is_some() && !captures.unwrap().is_empty(),
"failed step should have failure_captures: {failed:#}"
);
let pane = &captures.unwrap()[0];
assert!(
pane["screen_text"]
.as_str()
.unwrap_or("")
.contains("real_output"),
"failure_captures pane should contain real_output: {pane:#}"
);
assert!(
json.get("sandbox_root").is_some() && !json["sandbox_root"].is_null(),
"failed playbook should have sandbox_root: {json:#}"
);
let root = json["sandbox_root"].as_str().unwrap();
assert!(
root.contains("bpb-"),
"sandbox_root should be a bpb temp dir: {root}"
);
}
#[test]
fn parse_and_validate_fixtures() {
let fixtures = [
"alt_screen_exit_cursor.dsl",
"synthetic_alt_sigint_reentry.dsl",
"synthetic_alt_split_exit_reentry.dsl",
"echo_hello.dsl",
"echo_assert.dsl",
"multi_pane.dsl",
"wait_for_regex.dsl",
"attach_scrollback.dsl",
"send_keys_in_scrollback.dsl",
"env_mode_clean.dsl",
"screen_status.dsl",
"snapshot_capture.dsl",
"failing_assert.dsl",
"assert_matches.dsl",
"include_main.dsl",
"timeout_wait_for.dsl",
"timeout_playbook.dsl",
"var_override.dsl",
"continue_on_error.dsl",
"shell_exit.dsl",
"assert_matches_fail.dsl",
"render_assert_idle.dsl",
"render_assert_single_line_output.dsl",
"render_assert_exact_trace_single_line.dsl",
"render_assert_status_only.dsl",
"render_assert_focus_change.dsl",
"render_assert_split_pane.dsl",
"render_assert_resize_viewport.dsl",
"render_assert_alt_screen_transition.dsl",
"structured_reflow_basic.dsl",
"structured_reflow_layout.dsl",
"structured_reflow_scrollback.dsl",
];
for name in fixtures.iter().chain(ATTACH_SIM_FIXTURES.iter()) {
let path = fixtures_dir().join(name);
let playbook = bmux_cli::playbook::parse_file(&path)
.unwrap_or_else(|e| panic!("failed to parse {name}: {e:#}"));
let errors = bmux_cli::playbook::validate(&playbook, false);
assert!(
errors.is_empty(),
"validation errors for {name}: {errors:?}"
);
}
}
#[test]
fn attach_sim_fixtures_run_without_sandbox() {
for name in ATTACH_SIM_FIXTURES {
let (json, pass) = run_playbook_fixture(name);
assert!(pass, "attach-sim fixture failed: {name}");
if *name == "attach_sim_status_snapshot.dsl" {
assert_eq!(json["snapshots"][0]["id"], "initial-status");
assert!(
json["snapshots"][0]["panes"][0]["screen_text"]
.as_str()
.unwrap_or_default()
.contains("1:one")
);
}
}
}
#[test]
fn playbook_render_assert_idle() {
assert_render_fixture_passes("render_assert_idle.dsl");
}
#[test]
fn playbook_render_assert_single_line_output() {
assert_render_fixture_passes("render_assert_single_line_output.dsl");
}
#[test]
fn playbook_render_assert_exact_trace_single_line() {
assert_render_fixture_passes("render_assert_exact_trace_single_line.dsl");
}
#[test]
fn playbook_render_assert_status_only() {
assert_render_fixture_passes("render_assert_status_only.dsl");
}
#[test]
fn playbook_render_assert_focus_change() {
assert_render_fixture_passes("render_assert_focus_change.dsl");
}
#[test]
fn playbook_render_assert_split_pane() {
assert_render_fixture_passes("render_assert_split_pane.dsl");
}
#[test]
fn playbook_render_assert_resize_viewport() {
assert_render_fixture_passes("render_assert_resize_viewport.dsl");
}
#[test]
fn playbook_render_assert_alt_screen_transition() {
assert_render_fixture_passes("render_assert_alt_screen_transition.dsl");
}
#[test]
#[serial]
fn playbook_structured_reflow_basic() {
let (json, pass) = run_playbook_fixture("structured_reflow_basic.dsl");
assert!(pass, "structured reflow playbook should pass: {json:#}");
}
#[test]
#[serial]
fn playbook_structured_reflow_layout_changes() {
let (json, pass) = run_playbook_fixture("structured_reflow_layout.dsl");
assert!(
pass,
"structured reflow layout playbook should pass: {json:#}"
);
}
#[test]
#[serial]
fn playbook_structured_reflow_scrollback() {
let (json, pass) = run_playbook_fixture("structured_reflow_scrollback.dsl");
assert!(
pass,
"structured reflow scrollback playbook should pass: {json:#}"
);
}
fn assert_render_fixture_passes(fixture: &str) {
let (json, pass) = run_playbook_fixture(fixture);
assert!(pass, "render assertion should pass: {json:#}");
let steps = json["steps"].as_array().unwrap();
let assert_step = steps
.iter()
.find(|step| step["action"] == "assert-render")
.expect("assert-render step should be present");
assert_eq!(assert_step["status"], "pass");
assert!(
assert_step["detail"]
.as_str()
.is_some_and(|detail| detail.contains("render assertion passed")),
"assert-render detail should summarize pass: {assert_step:#}"
);
}
#[test]
fn playbook_assert_matches() {
let (json, pass) = run_playbook_fixture("assert_matches.dsl");
assert!(pass, "assert-screen matches= should pass: {json:#}");
let steps = json["steps"].as_array().unwrap();
let assert_step = steps
.iter()
.find(|s| s["action"] == "assert-screen")
.expect("should have an assert-screen step");
assert_eq!(
assert_step["status"], "pass",
"assert-screen matches= should pass"
);
}
#[test]
fn playbook_dry_run() {
let fixture = fixtures_dir().join("echo_hello.dsl");
let sandbox = BmuxCommandSandbox::new("dry-run");
let output = sandbox
.command()
.args(["playbook", "dry-run", "--json", fixture.to_str().unwrap()])
.output()
.expect("failed to run bmux playbook dry-run");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let json: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("JSON parse failed: {e}\nstdout: {stdout}\nstderr: {stderr}"));
assert_eq!(json["valid"], true, "dry-run should be valid: {json:#}");
let steps = json["steps"].as_array().expect("should have steps array");
assert!(!steps.is_empty(), "dry-run should list steps: {json:#}");
for step in steps {
assert!(
step.get("index").is_some(),
"step should have index: {step:#}"
);
assert!(
step.get("action").is_some(),
"step should have action: {step:#}"
);
assert!(step.get("dsl").is_some(), "step should have dsl: {step:#}");
}
let first_dsl = steps[0]["dsl"].as_str().unwrap_or("");
assert!(
first_dsl.starts_with("new-session"),
"first step dsl should be new-session: {first_dsl}"
);
assert!(
json.get("config").is_some(),
"dry-run should have config: {json:#}"
);
let errors = json["errors"].as_array().expect("should have errors array");
assert!(errors.is_empty(), "should have no errors: {json:#}");
assert!(output.status.success(), "dry-run exit code should be 0");
}
#[tokio::test]
async fn run_playbook_echo_pass() {
let dsl = "\
@viewport cols=80 rows=24
@shell sh
new-session
send-keys keys='echo api_test_marker\\r'
wait-for pattern='api_test_marker'
assert-screen contains='api_test_marker'
";
let (mut playbook, _) = bmux_cli::playbook::parse_dsl::parse_dsl(dsl).unwrap();
playbook.config.binary = Some(PathBuf::from(env!("CARGO_BIN_EXE_bmux")));
let result = bmux_cli::playbook::run(playbook, false).await.unwrap();
assert!(result.pass, "playbook should pass: {:?}", result.error);
assert!(
result
.steps
.iter()
.all(|s| s.status == bmux_cli::playbook::types::StepStatus::Pass),
"all steps should pass: {:?}",
result.steps
);
}
#[tokio::test]
async fn run_playbook_failing_returns_fail() {
let dsl = "\
@viewport cols=80 rows=24
@shell sh
new-session
send-keys keys='echo real_output\\r'
wait-for pattern='real_output'
assert-screen contains='nonexistent_xyz_api'
";
let (mut playbook, _) = bmux_cli::playbook::parse_dsl::parse_dsl(dsl).unwrap();
playbook.config.binary = Some(PathBuf::from(env!("CARGO_BIN_EXE_bmux")));
let result = bmux_cli::playbook::run(playbook, false).await.unwrap();
assert!(!result.pass, "playbook should fail");
let failed = result
.steps
.iter()
.find(|s| s.status == bmux_cli::playbook::types::StepStatus::Fail);
assert!(failed.is_some(), "should have a failed step");
let f = failed.unwrap();
assert!(f.expected.is_some(), "failed step should have expected");
assert!(f.actual.is_some(), "failed step should have actual");
assert!(
f.failure_captures.is_some(),
"failed step should have failure_captures"
);
}
#[test]
fn playbook_include_basic() {
let (json, pass) = run_playbook_fixture("include_main.dsl");
assert!(pass, "include playbook should pass: {json:#}");
let steps = json["steps"].as_array().expect("steps should be array");
assert!(
steps.len() >= 4,
"should have steps from both files: {json:#}"
);
assert_eq!(
steps[0]["action"], "new-session",
"first step should be new-session from include"
);
let last = steps.last().unwrap();
assert_eq!(
last["action"], "assert-screen",
"last step should be assert-screen from main file: {json:#}"
);
}
#[test]
fn playbook_include_validates() {
let path = fixtures_dir().join("include_main.dsl");
let playbook = bmux_cli::playbook::parse_file(&path)
.unwrap_or_else(|e| panic!("failed to parse include_main.dsl: {e:#}"));
let errors = bmux_cli::playbook::validate(&playbook, false);
assert!(
errors.is_empty(),
"include playbook should validate: {errors:?}"
);
assert!(
playbook.steps.len() >= 4,
"should have merged steps from include: {} steps",
playbook.steps.len()
);
}
#[test]
fn playbook_wait_for_timeout() {
let (json, pass) = run_playbook_fixture("timeout_wait_for.dsl");
assert!(!pass, "wait-for timeout playbook should fail: {json:#}");
let steps = json["steps"].as_array().expect("steps should be array");
let wait_step = steps
.iter()
.find(|s| s["action"] == "wait-for" && s["status"] == "fail");
assert!(
wait_step.is_some(),
"should have a failed wait-for step: {json:#}"
);
let ws = wait_step.unwrap();
let detail = ws["detail"].as_str().unwrap_or("");
assert!(
detail.contains("timed out"),
"detail should mention timeout: {detail}"
);
assert!(
ws.get("expected").is_some() && !ws["expected"].is_null(),
"wait-for timeout should have expected field: {ws:#}"
);
assert!(
ws.get("actual").is_some() && !ws["actual"].is_null(),
"wait-for timeout should have actual field: {ws:#}"
);
}
#[test]
fn playbook_level_timeout_skips_remaining() {
let (json, pass) = run_playbook_fixture("timeout_playbook.dsl");
assert!(!pass, "playbook timeout should fail: {json:#}");
let steps = json["steps"].as_array().expect("steps should be array");
let skipped = steps.iter().find(|s| s["status"] == "skip");
assert!(
skipped.is_some(),
"should have a skipped step from playbook timeout: {json:#}"
);
let detail = skipped.unwrap()["detail"].as_str().unwrap_or("");
assert!(
detail.contains("playbook timeout"),
"skipped step should mention playbook timeout: {detail}"
);
let error = json["error"].as_str().unwrap_or("");
let has_skip = steps.iter().any(|s| s["status"] == "skip");
assert!(
error.contains("exceeded after") || error.contains("timeout") || has_skip,
"should show timeout info: error={error}, steps={json:#}"
);
}
#[test]
fn playbook_from_recording_cli() {
let fixture = fixtures_dir().join("echo_hello.dsl");
let sandbox = BmuxCommandSandbox::new("from-recording");
let output = sandbox
.command()
.args([
"playbook",
"run",
"--json",
"--record",
fixture.to_str().unwrap(),
])
.env("BMUX_PLAYBOOK_ENV_MODE", "inherit")
.output()
.expect("failed to run bmux playbook with recording");
let stdout = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value = match serde_json::from_str(&stdout) {
Ok(j) => j,
Err(e) => {
eprintln!("JSON parse failed (recording run): {e}\nstdout: {stdout}");
return; }
};
let recording_id = match json["recording_id"].as_str() {
Some(id) => id.to_string(),
None => {
eprintln!("No recording_id in output, skipping from-recording test");
return; }
};
let from_output = sandbox
.command()
.args(["playbook", "from-recording", &recording_id])
.output()
.expect("failed to run bmux playbook from-recording");
let dsl = String::from_utf8_lossy(&from_output.stdout);
assert!(
from_output.status.success(),
"from-recording should succeed. stderr: {}",
String::from_utf8_lossy(&from_output.stderr)
);
assert!(
dsl.contains("new-session"),
"generated DSL should contain new-session: {dsl}"
);
assert!(
dsl.contains("send-keys"),
"generated DSL should contain send-keys: {dsl}"
);
let parse_result = bmux_cli::playbook::parse_dsl::parse_dsl(&dsl);
assert!(
parse_result.is_ok(),
"generated DSL should parse: {:?}",
parse_result.err()
);
}
#[test]
fn playbook_assert_matches_fail() {
let (json, pass) = run_playbook_fixture("assert_matches_fail.dsl");
assert!(
!pass,
"matches= on non-matching regex should fail: {json:#}"
);
let steps = json["steps"].as_array().expect("steps should be array");
let failed = steps
.iter()
.find(|s| s["action"] == "assert-screen" && s["status"] == "fail");
assert!(
failed.is_some(),
"should have a failed assert-screen step: {json:#}"
);
let f = failed.unwrap();
assert!(
f.get("expected").is_some() && !f["expected"].is_null(),
"should have expected field: {f:#}"
);
assert!(
f.get("actual").is_some() && !f["actual"].is_null(),
"should have actual field (screen text): {f:#}"
);
assert!(
f.get("failure_captures").is_some() && !f["failure_captures"].is_null(),
"should have failure_captures: {f:#}"
);
assert!(
f["actual"].as_str().unwrap_or("").contains("real_output"),
"actual should contain 'real_output': {f:#}"
);
}
#[test]
fn playbook_assert_matches_invalid_regex() {
let (json, pass) = run_playbook_fixture("assert_matches_invalid_regex.dsl");
assert!(!pass, "invalid regex should fail: {json:#}");
let steps = json["steps"].as_array().expect("steps should be array");
let failed = steps
.iter()
.find(|s| s["action"] == "assert-screen" && s["status"] == "fail");
assert!(
failed.is_some(),
"should have a failed assert-screen step: {json:#}"
);
let detail = failed.unwrap()["detail"].as_str().unwrap_or("");
assert!(
detail.to_lowercase().contains("regex"),
"detail should mention regex error: {detail}"
);
}
struct ProcessGuard {
pid: u32,
}
impl ProcessGuard {
fn new(child: &std::process::Child) -> Self {
Self { pid: child.id() }
}
}
impl Drop for ProcessGuard {
fn drop(&mut self) {
let _ = std::process::Command::new("kill")
.args(["-9", &self.pid.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
}
#[tokio::test]
#[serial]
async fn interactive_mode_basic() {
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader as TokioBufReader};
let sandbox = BmuxCommandSandbox::new("interactive-mode-basic");
let mut child = sandbox
.command()
.args([
"playbook",
"interactive",
"--viewport",
"80x24",
"--shell",
"sh",
"--timeout",
"120",
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn bmux playbook interactive");
let _guard = ProcessGuard::new(&child);
let stdout = child.stdout.take().expect("stdout should be piped");
let mut stdout_reader = std::io::BufReader::new(stdout);
let mut ready_line = String::new();
std::io::BufRead::read_line(&mut stdout_reader, &mut ready_line)
.expect("failed to read ready message");
let ready: serde_json::Value = serde_json::from_str(ready_line.trim())
.unwrap_or_else(|e| panic!("failed to parse ready message: {e}\nline: {ready_line}"));
assert_eq!(ready["status"], "ready", "ready message: {ready:#}");
let socket_path = ready["socket"]
.as_str()
.expect("ready message should have socket path");
let endpoint = endpoint_from_socket_path(socket_path);
let mut stream = None;
for _ in 0..10 {
match bmux_ipc::transport::LocalIpcStream::connect(&endpoint).await {
Ok(s) => {
stream = Some(s);
break;
}
Err(_) => tokio::time::sleep(std::time::Duration::from_millis(100)).await,
}
}
let stream = stream.unwrap_or_else(|| panic!("failed to connect to socket: {socket_path}"));
let (reader, mut writer) = tokio::io::split(stream);
let mut reader = TokioBufReader::new(reader);
async fn send_op(
writer: &mut (impl tokio::io::AsyncWrite + Unpin),
reader: &mut (impl AsyncBufReadExt + Unpin),
payload: &serde_json::Value,
) -> serde_json::Value {
let request_id = payload
.get("request_id")
.and_then(serde_json::Value::as_str)
.map(std::string::ToString::to_string);
writer
.write_all(format!("{}\n", payload).as_bytes())
.await
.expect("failed to write command payload");
writer.flush().await.expect("failed to flush");
loop {
let mut line = String::new();
reader
.read_line(&mut line)
.await
.expect("failed to read response");
let parsed: serde_json::Value = serde_json::from_str(line.trim())
.unwrap_or_else(|e| panic!("failed to parse response: {e}\nline: {line}"));
match request_id.as_deref() {
Some(expected) => {
if parsed
.get("request_id")
.and_then(serde_json::Value::as_str)
.is_some_and(|actual| actual == expected)
{
return parsed;
}
}
None => return parsed,
}
}
}
let resp = send_op(
&mut writer,
&mut reader,
&serde_json::json!({"op":"command","request_id":"basic-new","dsl":"new-session"}),
)
.await;
assert_eq!(resp["status"], "ok", "new-session response: {resp:#}");
assert_eq!(resp["action"], "new-session");
let resp = send_op(
&mut writer,
&mut reader,
&serde_json::json!({"op":"command","request_id":"basic-keys","dsl":"send-keys keys='echo interactive_test\\r'"}),
)
.await;
assert_eq!(resp["status"], "ok", "send-keys response: {resp:#}");
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let resp = send_op(
&mut writer,
&mut reader,
&serde_json::json!({"op":"hydrate","request_id":"basic-screen","kind":"screen_full"}),
)
.await;
assert_eq!(resp["status"], "ok", "screen response: {resp:#}");
let panes = resp["panes"].as_array().expect("screen should have panes");
assert!(!panes.is_empty(), "should have at least one pane");
let screen_text = panes[0]["screen_text"].as_str().unwrap_or("");
assert!(
screen_text.contains("interactive_test"),
"screen should contain 'interactive_test': {screen_text}"
);
let resp = send_op(
&mut writer,
&mut reader,
&serde_json::json!({"op":"command","request_id":"basic-assert-ok","dsl":"assert-screen contains='interactive_test'"}),
)
.await;
assert_eq!(resp["status"], "ok", "assert-screen response: {resp:#}");
let resp = send_op(
&mut writer,
&mut reader,
&serde_json::json!({"op":"status","request_id":"basic-status"}),
)
.await;
assert_eq!(resp["status"], "ok", "status response: {resp:#}");
assert!(
resp.get("session_id").is_some() && !resp["session_id"].is_null(),
"status should have session_id: {resp:#}"
);
assert!(
resp.get("pane_count").is_some(),
"status should have pane_count: {resp:#}"
);
assert!(
resp.get("focused_pane").is_some(),
"status should have focused_pane: {resp:#}"
);
let resp = send_op(
&mut writer,
&mut reader,
&serde_json::json!({"op":"command","request_id":"basic-assert-fail","dsl":"assert-screen contains='nonexistent_interactive_xyz'"}),
)
.await;
assert_eq!(resp["status"], "fail", "assert should fail: {resp:#}");
let fail_panes = resp["panes"].as_array();
assert!(
fail_panes.is_some() && !fail_panes.unwrap().is_empty(),
"interactive failure should include pane captures: {resp:#}"
);
let resp = send_op(
&mut writer,
&mut reader,
&serde_json::json!({"op":"subscribe","request_id":"basic-subscribe","event_types":["pane_output","cursor_delta","screen_delta","watchpoint_hit"]}),
)
.await;
assert_eq!(resp["status"], "ok", "subscribe response: {resp:#}");
assert_eq!(resp["action"], "subscribe");
let resp = send_op(
&mut writer,
&mut reader,
&serde_json::json!({"op":"command","request_id":"basic-keys-2","dsl":"send-keys keys='echo push_test_marker\\r'"}),
)
.await;
assert_eq!(resp["status"], "ok", "send-keys response: {resp:#}");
let resp = send_op(
&mut writer,
&mut reader,
&serde_json::json!({"op":"command","request_id":"basic-wait-marker","dsl":"wait-for pattern='push_test_marker' timeout=3000"}),
)
.await;
assert_eq!(resp["status"], "ok", "wait-for marker response: {resp:#}");
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let resp = send_op(
&mut writer,
&mut reader,
&serde_json::json!({"op":"hydrate","request_id":"basic-screen-2","kind":"screen_full"}),
)
.await;
assert_eq!(resp["status"], "ok", "screen after subscribe: {resp:#}");
let empty_vec = vec![];
let panes = resp["panes"].as_array().unwrap_or(&empty_vec);
let screen_has_marker = panes.iter().any(|p| {
p["screen_text"]
.as_str()
.unwrap_or("")
.contains("push_test_marker")
});
assert!(
screen_has_marker,
"screen should contain push_test_marker after subscribe"
);
let resp = send_op(
&mut writer,
&mut reader,
&serde_json::json!({"op":"unsubscribe","request_id":"basic-unsubscribe"}),
)
.await;
assert_eq!(resp["status"], "ok", "unsubscribe response: {resp:#}");
assert_eq!(resp["action"], "unsubscribe");
let resp = send_op(
&mut writer,
&mut reader,
&serde_json::json!({"op":"quit","request_id":"basic-quit"}),
)
.await;
assert_eq!(resp["status"], "ok", "quit response: {resp:#}");
assert_eq!(resp["action"], "quit");
let _ = child.wait();
}
#[tokio::test]
#[serial]
async fn interactive_mode_json_screen_delta_formats() {
use std::process::Stdio;
use tokio::io::BufReader as TokioBufReader;
let sandbox = BmuxCommandSandbox::new("interactive-mode-screen-delta");
let mut child = sandbox
.command()
.args([
"playbook",
"interactive",
"--viewport",
"80x24",
"--shell",
"sh",
"--timeout",
"120",
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn bmux playbook interactive");
let _guard = ProcessGuard::new(&child);
let stdout = child.stdout.take().expect("stdout should be piped");
let mut stdout_reader = std::io::BufReader::new(stdout);
let mut ready_line = String::new();
std::io::BufRead::read_line(&mut stdout_reader, &mut ready_line)
.expect("failed to read ready message");
let ready: serde_json::Value = serde_json::from_str(ready_line.trim())
.unwrap_or_else(|e| panic!("failed to parse ready message: {e}\nline: {ready_line}"));
let socket_path = ready["socket"]
.as_str()
.expect("ready message should have socket path");
let endpoint = endpoint_from_socket_path(socket_path);
let mut stream = None;
for _ in 0..10 {
match bmux_ipc::transport::LocalIpcStream::connect(&endpoint).await {
Ok(s) => {
stream = Some(s);
break;
}
Err(_) => tokio::time::sleep(std::time::Duration::from_millis(100)).await,
}
}
let stream = stream.unwrap_or_else(|| panic!("failed to connect to socket: {socket_path}"));
let (reader, mut writer) = tokio::io::split(stream);
let mut reader = TokioBufReader::new(reader);
send_json_line(
&mut writer,
&serde_json::json!({"op":"command","request_id":"r-new","dsl":"new-session"}),
)
.await;
let new_session = read_response_for_request_id(&mut reader, "r-new").await;
assert_eq!(new_session["action"], "new-session", "new-session response");
assert_eq!(new_session["request_id"], "r-new");
send_json_line(
&mut writer,
&serde_json::json!({
"op":"subscribe",
"request_id":"r-sub-1",
"client":"llm-agent",
"event_types":["screen_delta"],
"screen_delta_format":"line_ops"
}),
)
.await;
let subscribe_1 = read_response_for_request_id(&mut reader, "r-sub-1").await;
assert_eq!(subscribe_1["action"], "subscribe");
send_json_line(
&mut writer,
&serde_json::json!({"op":"command","request_id":"r-keys-1","dsl":"send-keys keys='echo line_ops_marker\\r'"}),
)
.await;
let keys_1 = read_response_for_request_id(&mut reader, "r-keys-1").await;
assert_eq!(keys_1["status"], "ok");
let line_ops_event = read_until_json(&mut reader, 80, "line_ops screen_delta", |msg| {
msg["type"] == "event" && msg["event_type"] == "screen_delta"
})
.await;
assert_eq!(line_ops_event["screen_delta"]["format"], "line_ops");
send_json_line(
&mut writer,
&serde_json::json!({
"op":"subscribe",
"request_id":"r-sub-2",
"event_types":["screen_delta"],
"screen_delta_format":"unified_diff"
}),
)
.await;
let subscribe_2 = read_response_for_request_id(&mut reader, "r-sub-2").await;
assert_eq!(subscribe_2["action"], "subscribe");
send_json_line(
&mut writer,
&serde_json::json!({"op":"command","request_id":"r-keys-2","dsl":"send-keys keys='echo unified_diff_marker\\r'"}),
)
.await;
let keys_2 = read_response_for_request_id(&mut reader, "r-keys-2").await;
assert_eq!(keys_2["status"], "ok");
let unified_diff_event = read_until_json(&mut reader, 80, "unified_diff screen_delta", |msg| {
msg["type"] == "event" && msg["event_type"] == "screen_delta"
})
.await;
assert_eq!(unified_diff_event["screen_delta"]["format"], "unified_diff");
let diff = unified_diff_event["screen_delta"]["diff"]
.as_str()
.unwrap_or("");
assert!(
diff.contains("@@"),
"unified diff payload should include hunks"
);
send_json_line(
&mut writer,
&serde_json::json!({"op":"quit","request_id":"r-quit"}),
)
.await;
let quit = read_response_for_request_id(&mut reader, "r-quit").await;
assert_eq!(quit["action"], "quit", "expected quit response");
let _ = child.wait();
}
#[tokio::test]
#[serial]
async fn interactive_mode_watchpoint_event_burst_cursor_delta_hit() {
use std::process::Stdio;
use tokio::io::BufReader as TokioBufReader;
let sandbox = BmuxCommandSandbox::new("interactive-mode-watchpoint-cursor");
let mut child = sandbox
.command()
.args([
"playbook",
"interactive",
"--viewport",
"80x24",
"--shell",
"sh",
"--timeout",
"120",
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn bmux playbook interactive");
let _guard = ProcessGuard::new(&child);
let stdout = child.stdout.take().expect("stdout should be piped");
let mut stdout_reader = std::io::BufReader::new(stdout);
let mut ready_line = String::new();
std::io::BufRead::read_line(&mut stdout_reader, &mut ready_line)
.expect("failed to read ready message");
let ready: serde_json::Value = serde_json::from_str(ready_line.trim())
.unwrap_or_else(|e| panic!("failed to parse ready message: {e}\nline: {ready_line}"));
let socket_path = ready["socket"]
.as_str()
.expect("ready message should have socket path");
let endpoint = endpoint_from_socket_path(socket_path);
let mut stream = None;
for _ in 0..10 {
match bmux_ipc::transport::LocalIpcStream::connect(&endpoint).await {
Ok(s) => {
stream = Some(s);
break;
}
Err(_) => tokio::time::sleep(std::time::Duration::from_millis(100)).await,
}
}
let stream = stream.unwrap_or_else(|| panic!("failed to connect to socket: {socket_path}"));
let (reader, mut writer) = tokio::io::split(stream);
let mut reader = TokioBufReader::new(reader);
send_json_line(
&mut writer,
&serde_json::json!({"op":"command","request_id":"wp-new","dsl":"new-session"}),
)
.await;
let new_session = read_response_for_request_id(&mut reader, "wp-new").await;
assert_eq!(new_session["action"], "new-session");
send_json_line(
&mut writer,
&serde_json::json!({
"op":"subscribe",
"request_id":"wp-sub",
"event_types":["watchpoint_hit"],
"pane_indexes":[1]
}),
)
.await;
let subscribed = read_response_for_request_id(&mut reader, "wp-sub").await;
assert_eq!(subscribed["action"], "subscribe");
send_json_line(
&mut writer,
&serde_json::json!({
"op":"set_watchpoint",
"request_id":"wp-set",
"id":"cursor-delta-burst-1",
"kind":"event_burst",
"event_type":"cursor_delta",
"pane_index":1,
"min_hits":1,
"window_ms":500
}),
)
.await;
let set_resp = read_response_for_request_id(&mut reader, "wp-set").await;
assert_eq!(set_resp["action"], "set_watchpoint");
send_json_line(
&mut writer,
&serde_json::json!({"op":"command","request_id":"wp-keys","dsl":"send-keys keys='echo watchpoint_cursor_marker\\r'"}),
)
.await;
let key_resp = read_response_for_request_id(&mut reader, "wp-keys").await;
assert_eq!(key_resp["status"], "ok");
let hit = read_until_json(&mut reader, 120, "cursor_delta watchpoint hit", |msg| {
msg["type"] == "event"
&& msg["event_type"] == "watchpoint_hit"
&& msg["watchpoint_hit"]["id"] == "cursor-delta-burst-1"
})
.await;
assert_eq!(hit["watchpoint_hit"]["kind"], "event_burst");
assert_eq!(hit["watchpoint_hit"]["watch_event_type"], "cursor_delta");
send_json_line(
&mut writer,
&serde_json::json!({"op":"clear_watchpoint","request_id":"wp-clear","id":"cursor-delta-burst-1"}),
)
.await;
let clear_resp = read_response_for_request_id(&mut reader, "wp-clear").await;
assert_eq!(clear_resp["action"], "clear_watchpoint");
send_json_line(
&mut writer,
&serde_json::json!({"op":"quit","request_id":"wp-quit"}),
)
.await;
let quit = read_response_for_request_id(&mut reader, "wp-quit").await;
assert_eq!(quit["action"], "quit", "expected quit response");
let _ = child.wait();
}
#[tokio::test]
#[serial]
async fn interactive_mode_watchpoint_event_burst_screen_delta_hit_and_blocks_recursive() {
use std::process::Stdio;
use tokio::io::BufReader as TokioBufReader;
let sandbox = BmuxCommandSandbox::new("interactive-mode-watchpoint-screen");
let mut child = sandbox
.command()
.args([
"playbook",
"interactive",
"--viewport",
"80x24",
"--shell",
"sh",
"--timeout",
"120",
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn bmux playbook interactive");
let _guard = ProcessGuard::new(&child);
let stdout = child.stdout.take().expect("stdout should be piped");
let mut stdout_reader = std::io::BufReader::new(stdout);
let mut ready_line = String::new();
std::io::BufRead::read_line(&mut stdout_reader, &mut ready_line)
.expect("failed to read ready message");
let ready: serde_json::Value = serde_json::from_str(ready_line.trim())
.unwrap_or_else(|e| panic!("failed to parse ready message: {e}\nline: {ready_line}"));
let socket_path = ready["socket"]
.as_str()
.expect("ready message should have socket path");
let endpoint = endpoint_from_socket_path(socket_path);
let mut stream = None;
for _ in 0..10 {
match bmux_ipc::transport::LocalIpcStream::connect(&endpoint).await {
Ok(s) => {
stream = Some(s);
break;
}
Err(_) => tokio::time::sleep(std::time::Duration::from_millis(100)).await,
}
}
let stream = stream.unwrap_or_else(|| panic!("failed to connect to socket: {socket_path}"));
let (reader, mut writer) = tokio::io::split(stream);
let mut reader = TokioBufReader::new(reader);
send_json_line(
&mut writer,
&serde_json::json!({"op":"command","request_id":"burst-new","dsl":"new-session"}),
)
.await;
let new_session = read_response_for_request_id(&mut reader, "burst-new").await;
assert_eq!(new_session["action"], "new-session");
send_json_line(
&mut writer,
&serde_json::json!({
"op":"subscribe",
"request_id":"burst-sub",
"event_types":["watchpoint_hit"],
"pane_indexes":[1]
}),
)
.await;
let subscribed = read_response_for_request_id(&mut reader, "burst-sub").await;
assert_eq!(subscribed["action"], "subscribe");
send_json_line(
&mut writer,
&serde_json::json!({
"op":"set_watchpoint",
"request_id":"burst-set-invalid",
"id":"blocked-watchpoint-hit",
"kind":"event_burst",
"event_type":"watchpoint_hit",
"min_hits":2,
"window_ms":1000
}),
)
.await;
let invalid_watchpoint = read_response_for_request_id(&mut reader, "burst-set-invalid").await;
assert_eq!(invalid_watchpoint["status"], "error");
send_json_line(
&mut writer,
&serde_json::json!({
"op":"set_watchpoint",
"request_id":"burst-set",
"id":"screen-delta-burst-1",
"kind":"event_burst",
"event_type":"screen_delta",
"pane_index":1,
"min_hits":1,
"window_ms":5000
}),
)
.await;
let set_resp = read_response_for_request_id(&mut reader, "burst-set").await;
assert_eq!(set_resp["action"], "set_watchpoint");
send_json_line(
&mut writer,
&serde_json::json!({"op":"command","request_id":"burst-keys-1","dsl":"send-keys keys='echo burst_one\\r'"}),
)
.await;
let keys_1 = read_response_for_request_id(&mut reader, "burst-keys-1").await;
assert_eq!(keys_1["status"], "ok");
send_json_line(
&mut writer,
&serde_json::json!({"op":"command","request_id":"burst-keys-2","dsl":"send-keys keys='echo burst_two\\r'"}),
)
.await;
let keys_2 = read_response_for_request_id(&mut reader, "burst-keys-2").await;
assert_eq!(keys_2["status"], "ok");
let hit = read_until_json(&mut reader, 120, "screen_delta watchpoint hit", |msg| {
msg["type"] == "event"
&& msg["event_type"] == "watchpoint_hit"
&& msg["watchpoint_hit"]["id"] == "screen-delta-burst-1"
})
.await;
assert_eq!(hit["watchpoint_hit"]["kind"], "event_burst");
assert_eq!(hit["watchpoint_hit"]["watch_event_type"], "screen_delta");
assert!(
hit["watchpoint_hit"]["observed_hits"].as_u64().unwrap_or(0) >= 1,
"expected observed hits >= 1"
);
send_json_line(
&mut writer,
&serde_json::json!({"op":"quit","request_id":"burst-quit"}),
)
.await;
let quit = read_response_for_request_id(&mut reader, "burst-quit").await;
assert_eq!(quit["action"], "quit", "expected quit response");
let _ = child.wait();
}
#[tokio::test]
async fn concurrent_playbook_runs() {
let dsl = "\
@viewport cols=80 rows=24
@shell sh
new-session
send-keys keys='echo concurrent_test\\r'
wait-for pattern='concurrent_test'
assert-screen contains='concurrent_test'
";
let run1 = async {
let (mut pb, _) = bmux_cli::playbook::parse_dsl::parse_dsl(dsl).unwrap();
pb.config.binary = Some(PathBuf::from(env!("CARGO_BIN_EXE_bmux")));
bmux_cli::playbook::run(pb, false).await.unwrap()
};
let run2 = async {
let (mut pb, _) = bmux_cli::playbook::parse_dsl::parse_dsl(dsl).unwrap();
pb.config.binary = Some(PathBuf::from(env!("CARGO_BIN_EXE_bmux")));
bmux_cli::playbook::run(pb, false).await.unwrap()
};
let (r1, r2) = tokio::join!(run1, run2);
assert!(r1.pass, "concurrent run 1 should pass: {:?}", r1.error);
assert!(r2.pass, "concurrent run 2 should pass: {:?}", r2.error);
}
#[test]
fn playbook_shell_exit_mid_playbook() {
let (json, pass) = run_playbook_fixture("shell_exit.dsl");
assert!(!pass, "should fail after shell exit: {json:#}");
let steps = json["steps"].as_array().expect("should have steps");
let failed = steps.iter().find(|s| s["status"] == "fail");
assert!(
failed.is_some(),
"should have a failed step after shell exit: {json:#}"
);
let detail = failed.unwrap()["detail"].as_str().unwrap_or("");
assert!(
!detail.is_empty(),
"failed step should have a detail: {json:#}"
);
}
#[test]
fn playbook_validate_valid_json() {
let fixture = fixtures_dir().join("echo_hello.dsl");
let sandbox = BmuxCommandSandbox::new("validate-valid-json");
let output = sandbox
.command()
.args(["playbook", "validate", "--json", fixture.to_str().unwrap()])
.output()
.expect("failed to run bmux playbook validate");
let stdout = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("JSON parse failed: {e}\nstdout: {stdout}"));
assert_eq!(json["valid"], true, "echo_hello should be valid: {json:#}");
let errors = json["errors"].as_array().expect("should have errors array");
assert!(errors.is_empty(), "should have no errors: {json:#}");
assert!(output.status.success(), "exit code should be 0");
}
#[test]
fn playbook_validate_invalid_json() {
let fixture = fixtures_dir().join("invalid_no_session.dsl");
let sandbox = BmuxCommandSandbox::new("validate-invalid-json");
let output = sandbox
.command()
.args(["playbook", "validate", "--json", fixture.to_str().unwrap()])
.output()
.expect("failed to run bmux playbook validate");
let stdout = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("JSON parse failed: {e}\nstdout: {stdout}"));
assert_eq!(
json["valid"], false,
"invalid playbook should not be valid: {json:#}"
);
let errors = json["errors"].as_array().expect("should have errors array");
assert!(
!errors.is_empty(),
"should have validation errors: {json:#}"
);
assert!(
!output.status.success(),
"exit code should be non-zero for invalid playbook"
);
}
fn run_playbook_fixture_raw(name: &str) -> String {
let fixture = fixtures_dir().join(name);
let sandbox = BmuxCommandSandbox::new("run-playbook-fixture-raw");
let output = sandbox
.command()
.args(["playbook", "run", "--json", fixture.to_str().unwrap()])
.env("BMUX_PLAYBOOK_ENV_MODE", "inherit")
.output()
.expect("failed to run bmux playbook");
String::from_utf8_lossy(&output.stdout).to_string()
}
#[test]
fn playbook_diff_detects_changes() {
let left_json = run_playbook_fixture_raw("echo_hello.dsl");
let right_json = run_playbook_fixture_raw("failing_assert.dsl");
let dir = std::env::temp_dir().join(format!("bmux-diff-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let left_path = dir.join("left.json");
let right_path = dir.join("right.json");
std::fs::write(&left_path, &left_json).unwrap();
std::fs::write(&right_path, &right_json).unwrap();
let sandbox = BmuxCommandSandbox::new("diff-detects-changes");
let output = sandbox
.command()
.args([
"playbook",
"diff",
"--json",
left_path.to_str().unwrap(),
right_path.to_str().unwrap(),
])
.output()
.expect("failed to run bmux playbook diff");
let stdout = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("JSON parse failed: {e}\nstdout: {stdout}"));
assert_eq!(
json["summary"]["outcome_changed"], true,
"outcome should have changed: {json:#}"
);
assert_eq!(json["summary"]["left_pass"], true);
assert_eq!(json["summary"]["right_pass"], false);
assert!(
json["summary"]["steps_changed"].as_u64().unwrap_or(0) > 0,
"should have at least one changed step: {json:#}"
);
let step_diffs = json["step_diffs"]
.as_array()
.expect("should have step_diffs");
assert!(!step_diffs.is_empty(), "step_diffs should not be empty");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn playbook_diff_identical() {
let result_json = run_playbook_fixture_raw("echo_hello.dsl");
let dir = std::env::temp_dir().join(format!("bmux-diff-identical-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("result.json");
std::fs::write(&path, &result_json).unwrap();
let sandbox = BmuxCommandSandbox::new("diff-identical");
let output = sandbox
.command()
.args([
"playbook",
"diff",
"--json",
path.to_str().unwrap(),
path.to_str().unwrap(),
])
.output()
.expect("failed to run bmux playbook diff");
let stdout = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("JSON parse failed: {e}\nstdout: {stdout}"));
assert_eq!(
json["summary"]["outcome_changed"], false,
"outcome should not change for identical results: {json:#}"
);
assert_eq!(
json["summary"]["steps_changed"].as_u64().unwrap_or(99),
0,
"no steps should change for identical results: {json:#}"
);
assert!(
output.status.success(),
"exit code should be 0 for identical results"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn playbook_var_cli_override() {
let fixture = fixtures_dir().join("var_override.dsl");
let sandbox = BmuxCommandSandbox::new("var-cli-override");
let output = sandbox
.command()
.args([
"playbook",
"run",
"--json",
"--var",
"MARKER=cli_override",
fixture.to_str().unwrap(),
])
.env("BMUX_PLAYBOOK_ENV_MODE", "inherit")
.output()
.expect("failed to run bmux playbook");
let stdout = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| {
let stderr = String::from_utf8_lossy(&output.stderr);
panic!("JSON parse failed: {e}\nstdout: {stdout}\nstderr: {stderr}")
});
let pass = json["pass"].as_bool().unwrap_or(false);
assert!(pass, "--var should override @var: {json:#}");
}
#[test]
fn playbook_continue_on_error() {
let (json, pass) = run_playbook_fixture("continue_on_error.dsl");
assert!(
!pass,
"playbook with failed !continue step should fail: {json:#}"
);
let steps = json["steps"].as_array().expect("steps should be array");
let assert_steps: Vec<_> = steps
.iter()
.filter(|s| s["action"] == "assert-screen")
.collect();
assert_eq!(
assert_steps.len(),
2,
"both asserts should execute: {json:#}"
);
assert_eq!(
assert_steps[0]["status"], "fail",
"first assert should fail"
);
assert_eq!(
assert_steps[1]["status"], "pass",
"second assert should pass"
);
}
#[test]
fn playbook_cleanup_dry_run() {
let sandbox = BmuxCommandSandbox::new("cleanup-dry-run");
let output = sandbox
.command()
.args(["playbook", "cleanup", "--json", "--dry-run"])
.output()
.expect("failed to run bmux playbook cleanup");
let stdout = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| {
let stderr = String::from_utf8_lossy(&output.stderr);
panic!("JSON parse failed: {e}\nstdout: {stdout}\nstderr: {stderr}")
});
assert!(
json.get("scanned").is_some(),
"should have scanned: {json:#}"
);
assert!(
json.get("orphaned").is_some(),
"should have orphaned: {json:#}"
);
assert!(output.status.success(), "cleanup should succeed");
}