use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::time::Duration;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct Playbook {
pub config: PlaybookConfig,
pub steps: Vec<Step>,
}
#[derive(Debug, Clone)]
pub struct PlaybookConfig {
pub name: Option<String>,
pub description: Option<String>,
pub viewport: Viewport,
pub shell: Option<String>,
pub timeout: Duration,
pub record: bool,
pub plugins: PluginConfig,
pub vars: BTreeMap<String, String>,
pub env: BTreeMap<String, String>,
pub env_mode: Option<SandboxEnvMode>,
pub binary: Option<std::path::PathBuf>,
pub bundled_plugin_ids: Vec<String>,
pub verbose: bool,
pub render_trace: bool,
pub driver: PlaybookDriver,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlaybookDriver {
Sandbox,
RealAttach,
AttachSim,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxEnvMode {
Inherit,
Clean,
}
impl PlaybookConfig {
#[must_use]
pub fn effective_env_mode(&self) -> SandboxEnvMode {
effective_env_mode_from(
self.env_mode,
std::env::var("BMUX_PLAYBOOK_ENV_MODE").ok().as_deref(),
)
}
}
fn effective_env_mode_from(
explicit: Option<SandboxEnvMode>,
env_mode: Option<&str>,
) -> SandboxEnvMode {
if let Some(mode) = explicit {
return mode;
}
match env_mode {
Some("clean") => SandboxEnvMode::Clean,
_ => SandboxEnvMode::Inherit,
}
}
impl Default for PlaybookConfig {
fn default() -> Self {
Self {
name: None,
description: None,
viewport: Viewport::default(),
shell: None,
timeout: Duration::from_secs(30),
record: false,
plugins: PluginConfig::default(),
vars: BTreeMap::new(),
env: BTreeMap::new(),
env_mode: None,
binary: None,
bundled_plugin_ids: Vec::new(),
verbose: false,
render_trace: false,
driver: PlaybookDriver::Sandbox,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Viewport {
pub cols: u16,
pub rows: u16,
}
impl Default for Viewport {
fn default() -> Self {
Self { cols: 80, rows: 24 }
}
}
#[derive(Debug, Clone, Default)]
pub struct PluginConfig {
pub enable: Vec<String>,
pub disable: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct Step {
pub index: usize,
pub action: Action,
pub continue_on_error: bool,
}
impl Step {
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn to_dsl(&self) -> String {
let line = self.action.to_dsl();
if self.continue_on_error {
format!("{line} !continue")
} else {
line
}
}
}
#[derive(Debug, Clone)]
pub enum Action {
NewSession { name: Option<String> },
KillSession { name: String },
SplitPane {
direction: SplitDirection,
#[allow(dead_code)]
ratio: Option<f64>,
},
FocusPane { target: u32 },
ClosePane { target: Option<u32> },
SendKeys { keys: Vec<u8>, pane: Option<u32> },
SendBytes { hex: Vec<u8> },
WaitFor {
pattern: String,
pane: Option<u32>,
timeout: Duration,
retry: u32,
},
Sleep { duration: Duration },
Snapshot { id: String },
AssertScreen {
pane: Option<u32>,
contains: Option<String>,
not_contains: Option<String>,
matches: Option<String>,
scrollback: bool,
},
AssertLayout { pane_count: u32 },
AssertCursor {
pane: Option<u32>,
row: u16,
col: u16,
},
ResizeViewport { cols: u16, rows: u16 },
SendAttach { key: String },
PrefixKey { key: char },
WaitForEvent { event: String, timeout: Duration },
InvokeService {
capability: String,
kind: ServiceKind,
interface_id: String,
operation: String,
payload: String,
},
Screen,
Status,
RenderMark { id: String },
AssertRender {
since: String,
assertion: RenderAssertion,
},
SeedWindowList { names: Vec<String>, active: String },
SeedPaneText {
lines: Vec<String>,
cursor_row: u16,
cursor_col: u16,
},
SeedPaneLayout { split: String },
Render,
Locate { id: String, text: String },
TerminalEvent(SimTerminalEvent),
AssertEffect { operation: String },
AssertNoEffect { operation: String },
AssertState { path: String, equals: String },
AssertRendered {
contains: Option<String>,
matches: Option<String>,
},
SetConfig { path: String, value: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SimTerminalEvent {
pub kind: String,
pub phase: String,
pub button: Option<String>,
pub col: String,
pub row: String,
}
#[derive(Debug, Clone, Default)]
pub struct RenderAssertion {
pub min_frames: Option<u64>,
pub max_frames: Option<u64>,
pub full_frame: Option<bool>,
pub max_full_frame_frames: Option<u64>,
pub max_full_surface_fallbacks: Option<u64>,
pub max_damage_rects: Option<u64>,
pub max_damage_area_cells: Option<u64>,
pub max_rows_emitted: Option<u64>,
pub max_row_segments_emitted: Option<u64>,
pub max_cells_emitted: Option<u64>,
pub max_frame_bytes: Option<u64>,
pub status_rendered: Option<bool>,
pub overlay_rendered: Option<bool>,
pub expected_emitted_rows: Option<Vec<PlaybookRenderRowRef>>,
pub expected_emitted_row_segments: Option<Vec<PlaybookRenderRowSegmentRef>>,
pub expected_trace_ops: Option<Vec<PlaybookRenderTraceOp>>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct PlaybookRenderRowRef {
pub pane: u32,
pub row: u16,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct PlaybookRenderRowSegmentRef {
pub pane: u32,
pub row: u16,
pub start_col: u16,
pub cells: u16,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum PlaybookRenderTraceOp {
FullFrame,
ClearRow {
row: u16,
cells: u16,
},
PaneRowFull {
pane: u32,
row: u16,
cells: u16,
},
PaneRowSegment {
pane: u32,
row: u16,
start_col: u16,
cells: u16,
},
PaneRowCacheSkip {
pane: u32,
row: u16,
},
PaneRowsSyncDeferred {
pane: u32,
rows: u16,
},
ExtensionOps {
surface: u32,
regions: u16,
full_surface: bool,
},
ExtensionCachedReplay {
surface: u32,
},
ExtensionImperative {
surface: u32,
regions: u16,
full_surface: bool,
},
StatusLine,
HelpOverlay,
PromptOverlay,
DamageOverlay {
rects: u16,
cells: u64,
},
Cursor {
pane: u32,
visible: bool,
},
Overlay,
}
impl PlaybookRenderTraceOp {
pub fn parse_compact(entry: &str) -> Result<Self, String> {
if entry == "full-frame" {
return Ok(Self::FullFrame);
}
if entry == "status-line" {
return Ok(Self::StatusLine);
}
if entry == "help-overlay" {
return Ok(Self::HelpOverlay);
}
if entry == "prompt-overlay" {
return Ok(Self::PromptOverlay);
}
if entry == "overlay" {
return Ok(Self::Overlay);
}
let parts = entry.split(':').collect::<Vec<_>>();
match parts.as_slice() {
["clear-row", row, cells] => Ok(Self::ClearRow {
row: parse_trace_part(row, "row")?,
cells: parse_trace_part(cells, "cells")?,
}),
["pane-row-full", pane, row, cells] => Ok(Self::PaneRowFull {
pane: parse_trace_part(pane, "pane")?,
row: parse_trace_part(row, "row")?,
cells: parse_trace_part(cells, "cells")?,
}),
["pane-row-segment", pane, row, start_col, cells] => Ok(Self::PaneRowSegment {
pane: parse_trace_part(pane, "pane")?,
row: parse_trace_part(row, "row")?,
start_col: parse_trace_part(start_col, "start_col")?,
cells: parse_trace_part(cells, "cells")?,
}),
["pane-row-cache-skip", pane, row] => Ok(Self::PaneRowCacheSkip {
pane: parse_trace_part(pane, "pane")?,
row: parse_trace_part(row, "row")?,
}),
["pane-rows-sync-deferred", pane, rows] => Ok(Self::PaneRowsSyncDeferred {
pane: parse_trace_part(pane, "pane")?,
rows: parse_trace_part(rows, "rows")?,
}),
["extension-ops", surface, regions, full_surface] => Ok(Self::ExtensionOps {
surface: parse_trace_part(surface, "surface")?,
regions: parse_trace_part(regions, "regions")?,
full_surface: parse_trace_part(full_surface, "full_surface")?,
}),
["extension-cached-replay", surface] => Ok(Self::ExtensionCachedReplay {
surface: parse_trace_part(surface, "surface")?,
}),
["extension-imperative", surface, regions, full_surface] => {
Ok(Self::ExtensionImperative {
surface: parse_trace_part(surface, "surface")?,
regions: parse_trace_part(regions, "regions")?,
full_surface: parse_trace_part(full_surface, "full_surface")?,
})
}
["damage-overlay", rects, cells] => Ok(Self::DamageOverlay {
rects: parse_trace_part(rects, "rects")?,
cells: parse_trace_part(cells, "cells")?,
}),
["cursor", pane, visible] => Ok(Self::Cursor {
pane: parse_trace_part(pane, "pane")?,
visible: parse_trace_part(visible, "visible")?,
}),
_ => Err(format!(
"invalid render trace op '{entry}', expected a compact semantic render op"
)),
}
}
}
fn parse_trace_part<T>(value: &str, name: &str) -> Result<T, String>
where
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
value
.parse()
.map_err(|error| format!("invalid trace op {name}: {error}"))
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct PlaybookRenderSummary {
pub frames: u64,
pub full_frame_frames: u64,
pub full_surface_fallbacks: u64,
pub damage_rects: u64,
pub damage_area_cells: u64,
pub rows_emitted: u64,
pub row_segments_emitted: u64,
pub cells_emitted: u64,
pub frame_bytes: u64,
pub status_rendered_frames: u64,
pub overlay_rendered_frames: u64,
pub terminal_graphic_transmits: u64,
pub terminal_graphic_places: u64,
pub terminal_graphic_deletes: u64,
pub terminal_graphic_bytes: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub emitted_rows: Vec<PlaybookRenderRowRef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub emitted_row_segments: Vec<PlaybookRenderRowSegmentRef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub trace_ops: Vec<PlaybookRenderTraceOp>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServiceKind {
Query,
Command,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SplitDirection {
Vertical,
Horizontal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaybookResult {
pub playbook_name: Option<String>,
pub pass: bool,
pub steps: Vec<StepResult>,
pub snapshots: Vec<SnapshotCapture>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recording_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recording_path: Option<String>,
pub total_elapsed_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox_root: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepResult {
pub index: usize,
pub action: String,
pub status: StepStatus,
pub elapsed_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expected: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub actual: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub failure_captures: Option<Vec<PaneCapture>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub render_summary: Option<PlaybookRenderSummary>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub continue_on_error: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StepStatus {
Pass,
Fail,
Skip,
}
#[derive(Debug)]
pub struct StepFailure {
pub message: String,
pub expected: Option<String>,
pub actual: Option<String>,
}
impl StepFailure {
pub fn msg(message: impl Into<String>) -> Self {
Self {
message: message.into(),
expected: None,
actual: None,
}
}
pub fn assertion(
message: impl Into<String>,
expected: impl Into<String>,
actual: impl Into<String>,
) -> Self {
Self {
message: message.into(),
expected: Some(expected.into()),
actual: Some(actual.into()),
}
}
}
impl std::fmt::Display for StepFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for StepFailure {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotCapture {
pub id: String,
pub panes: Vec<PaneCapture>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaneCapture {
pub index: u32,
pub focused: bool,
pub screen_text: String,
pub cursor_row: u16,
pub cursor_col: u16,
}
impl Action {
#[must_use]
pub const fn name(&self) -> &'static str {
match self {
Self::NewSession { .. } => "new-session",
Self::KillSession { .. } => "kill-session",
Self::SplitPane { .. } => "split-pane",
Self::FocusPane { .. } => "focus-pane",
Self::ClosePane { .. } => "close-pane",
Self::SendKeys { .. } => "send-keys",
Self::SendBytes { .. } => "send-bytes",
Self::WaitFor { .. } => "wait-for",
Self::Sleep { .. } => "sleep",
Self::Snapshot { .. } => "snapshot",
Self::AssertScreen { .. } => "assert-screen",
Self::AssertLayout { .. } => "assert-layout",
Self::AssertCursor { .. } => "assert-cursor",
Self::ResizeViewport { .. } => "resize-viewport",
Self::SendAttach { .. } => "send-attach",
Self::PrefixKey { .. } => "prefix-key",
Self::WaitForEvent { .. } => "wait-for-event",
Self::InvokeService { .. } => "invoke-service",
Self::Screen => "screen",
Self::Status => "status",
Self::RenderMark { .. } => "render-mark",
Self::AssertRender { .. } => "assert-render",
Self::SeedWindowList { .. } => "seed-window-list",
Self::SeedPaneText { .. } => "seed-pane-text",
Self::SeedPaneLayout { .. } => "seed-pane-layout",
Self::Render => "render",
Self::Locate { .. } => "locate",
Self::TerminalEvent(_) => "terminal-event",
Self::AssertEffect { .. } => "assert-effect",
Self::AssertNoEffect { .. } => "assert-no-effect",
Self::AssertState { .. } => "assert-state",
Self::AssertRendered { .. } => "assert-rendered",
Self::SetConfig { .. } => "set-config",
}
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn to_dsl(&self) -> String {
use super::from_recording::{bytes_to_c_escaped, escape_single_quote};
match self {
Self::NewSession { name } => name.as_ref().map_or_else(
|| "new-session".to_string(),
|n| format!("new-session name='{}'", escape_single_quote(n)),
),
Self::KillSession { name } => {
format!("kill-session name='{}'", escape_single_quote(name))
}
Self::SplitPane { direction, ratio } => {
let dir = match direction {
SplitDirection::Vertical => "vertical",
SplitDirection::Horizontal => "horizontal",
};
ratio.map_or_else(
|| format!("split-pane direction={dir}"),
|r| format!("split-pane direction={dir} ratio={r}"),
)
}
Self::FocusPane { target } => format!("focus-pane target={target}"),
Self::ClosePane { target } => target.map_or_else(
|| "close-pane".to_string(),
|t| format!("close-pane target={t}"),
),
Self::SendKeys { keys, pane } => {
let escaped = bytes_to_c_escaped(keys);
pane.map_or_else(
|| format!("send-keys keys='{escaped}'"),
|p| format!("send-keys keys='{escaped}' pane={p}"),
)
}
Self::SendBytes { hex } => {
let mut hex_str = String::with_capacity(hex.len() * 2);
for b in hex {
write!(hex_str, "{b:02x}").unwrap();
}
format!("send-bytes hex={hex_str}")
}
Self::WaitFor {
pattern,
pane,
timeout,
retry,
} => {
let escaped = escape_single_quote(pattern);
let mut line = format!("wait-for pattern='{escaped}'");
if let Some(p) = pane {
write!(line, " pane={p}").unwrap();
}
let ms = timeout.as_millis();
if ms != 5000 {
write!(line, " timeout={ms}").unwrap();
}
if *retry > 1 {
write!(line, " retry={retry}").unwrap();
}
line
}
Self::Sleep { duration } => format!("sleep ms={}", duration.as_millis()),
Self::Snapshot { id } => {
format!("snapshot id='{}'", escape_single_quote(id))
}
Self::AssertScreen {
pane,
contains,
not_contains,
matches,
scrollback,
} => {
let mut line = "assert-screen".to_string();
if let Some(p) = pane {
write!(line, " pane={p}").unwrap();
}
if let Some(c) = contains {
write!(line, " contains='{}'", escape_single_quote(c)).unwrap();
}
if let Some(nc) = not_contains {
write!(line, " not_contains='{}'", escape_single_quote(nc)).unwrap();
}
if let Some(m) = matches {
write!(line, " matches='{}'", escape_single_quote(m)).unwrap();
}
if *scrollback {
line.push_str(" scrollback=true");
}
line
}
Self::AssertLayout { pane_count } => {
format!("assert-layout pane_count={pane_count}")
}
Self::AssertCursor { pane, row, col } => {
let mut line = format!("assert-cursor row={row} col={col}");
if let Some(p) = pane {
write!(line, " pane={p}").unwrap();
}
line
}
Self::ResizeViewport { cols, rows } => {
format!("resize-viewport cols={cols} rows={rows}")
}
Self::SendAttach { key } => {
format!("send-attach key='{}'", escape_single_quote(key))
}
Self::PrefixKey { key } => format!("prefix-key key={key}"),
Self::WaitForEvent { event, timeout } => {
let escaped = escape_single_quote(event);
let ms = timeout.as_millis();
if ms == 5000 {
format!("wait-for-event event='{escaped}'")
} else {
format!("wait-for-event event='{escaped}' timeout={ms}")
}
}
Self::InvokeService {
capability,
kind,
interface_id,
operation,
payload,
} => {
let kind_str = match kind {
ServiceKind::Query => "query",
ServiceKind::Command => "command",
};
let mut line = format!(
"invoke-service capability='{}' interface='{}' operation='{}' kind={kind_str}",
escape_single_quote(capability),
escape_single_quote(interface_id),
escape_single_quote(operation),
);
if !payload.is_empty() {
write!(line, " payload='{}'", escape_single_quote(payload)).unwrap();
}
line
}
Self::Screen => "screen".to_string(),
Self::Status => "status".to_string(),
Self::RenderMark { id } => {
format!("render-mark id='{}'", escape_single_quote(id))
}
Self::AssertRender { since, assertion } => {
let mut line = format!("assert-render since='{}'", escape_single_quote(since));
append_render_assertion_dsl(&mut line, assertion);
line
}
Self::SeedWindowList { names, active } => format!(
"seed-window-list names='{}' active='{}'",
escape_single_quote(&names.join(",")),
escape_single_quote(active)
),
Self::SeedPaneText {
lines,
cursor_row,
cursor_col,
} => format!(
"seed-pane-text lines='{}' cursor_row={cursor_row} cursor_col={cursor_col}",
escape_single_quote(&lines.join("|"))
),
Self::SeedPaneLayout { split } => {
format!("seed-pane-layout split='{}'", escape_single_quote(split))
}
Self::Render => "render".to_string(),
Self::Locate { id, text } => format!(
"locate id='{}' text='{}'",
escape_single_quote(id),
escape_single_quote(text)
),
Self::TerminalEvent(event) => {
let mut line = format!(
"terminal-event kind={} phase={} col='{}' row='{}'",
event.kind,
event.phase,
escape_single_quote(&event.col),
escape_single_quote(&event.row)
);
if let Some(button) = &event.button {
write!(line, " button={button}").unwrap();
}
line
}
Self::AssertEffect { operation } => {
format!(
"assert-effect operation='{}'",
escape_single_quote(operation)
)
}
Self::AssertNoEffect { operation } => {
format!(
"assert-no-effect operation='{}'",
escape_single_quote(operation)
)
}
Self::AssertState { path, equals } => format!(
"assert-state path='{}' equals='{}'",
escape_single_quote(path),
escape_single_quote(equals)
),
Self::AssertRendered { contains, matches } => {
let mut line = "assert-rendered".to_string();
if let Some(value) = contains {
write!(line, " contains='{}'", escape_single_quote(value)).unwrap();
}
if let Some(value) = matches {
write!(line, " matches='{}'", escape_single_quote(value)).unwrap();
}
line
}
Self::SetConfig { path, value } => format!(
"set-config path='{}' value='{}'",
escape_single_quote(path),
escape_single_quote(value)
),
}
}
}
fn append_render_assertion_dsl(line: &mut String, assertion: &RenderAssertion) {
macro_rules! push_opt {
($field:ident) => {
if let Some(value) = assertion.$field {
write!(line, " {}={value}", stringify!($field)).unwrap();
}
};
}
push_opt!(min_frames);
push_opt!(max_frames);
push_opt!(full_frame);
push_opt!(max_full_frame_frames);
push_opt!(max_full_surface_fallbacks);
push_opt!(max_damage_rects);
push_opt!(max_damage_area_cells);
push_opt!(max_rows_emitted);
push_opt!(max_row_segments_emitted);
push_opt!(max_cells_emitted);
push_opt!(max_frame_bytes);
push_opt!(status_rendered);
push_opt!(overlay_rendered);
if let Some(rows) = &assertion.expected_emitted_rows {
write!(
line,
" expected_emitted_rows='{}'",
render_row_refs_to_dsl(rows)
)
.unwrap();
}
if let Some(segments) = &assertion.expected_emitted_row_segments {
write!(
line,
" expected_emitted_row_segments='{}'",
render_row_segment_refs_to_dsl(segments)
)
.unwrap();
}
if let Some(ops) = &assertion.expected_trace_ops {
write!(
line,
" expected_trace_ops='{}'",
render_trace_ops_to_dsl(ops)
)
.unwrap();
}
}
#[must_use]
pub fn render_row_refs_to_dsl(rows: &[PlaybookRenderRowRef]) -> String {
rows.iter()
.map(|row| format!("{}:{}", row.pane, row.row))
.collect::<Vec<_>>()
.join(",")
}
#[must_use]
pub fn render_row_segment_refs_to_dsl(segments: &[PlaybookRenderRowSegmentRef]) -> String {
segments
.iter()
.map(|segment| {
format!(
"{}:{}:{}:{}",
segment.pane, segment.row, segment.start_col, segment.cells
)
})
.collect::<Vec<_>>()
.join(",")
}
#[must_use]
pub fn render_trace_ops_to_dsl(ops: &[PlaybookRenderTraceOp]) -> String {
ops.iter()
.map(|op| match *op {
PlaybookRenderTraceOp::FullFrame => "full-frame".to_string(),
PlaybookRenderTraceOp::ClearRow { row, cells } => {
format!("clear-row:{row}:{cells}")
}
PlaybookRenderTraceOp::PaneRowFull { pane, row, cells } => {
format!("pane-row-full:{pane}:{row}:{cells}")
}
PlaybookRenderTraceOp::PaneRowSegment {
pane,
row,
start_col,
cells,
} => format!("pane-row-segment:{pane}:{row}:{start_col}:{cells}"),
PlaybookRenderTraceOp::PaneRowCacheSkip { pane, row } => {
format!("pane-row-cache-skip:{pane}:{row}")
}
PlaybookRenderTraceOp::PaneRowsSyncDeferred { pane, rows } => {
format!("pane-rows-sync-deferred:{pane}:{rows}")
}
PlaybookRenderTraceOp::ExtensionOps {
surface,
regions,
full_surface,
} => format!("extension-ops:{surface}:{regions}:{full_surface}"),
PlaybookRenderTraceOp::ExtensionCachedReplay { surface } => {
format!("extension-cached-replay:{surface}")
}
PlaybookRenderTraceOp::ExtensionImperative {
surface,
regions,
full_surface,
} => format!("extension-imperative:{surface}:{regions}:{full_surface}"),
PlaybookRenderTraceOp::StatusLine => "status-line".to_string(),
PlaybookRenderTraceOp::HelpOverlay => "help-overlay".to_string(),
PlaybookRenderTraceOp::PromptOverlay => "prompt-overlay".to_string(),
PlaybookRenderTraceOp::DamageOverlay { rects, cells } => {
format!("damage-overlay:{rects}:{cells}")
}
PlaybookRenderTraceOp::Cursor { pane, visible } => {
format!("cursor:{pane}:{visible}")
}
PlaybookRenderTraceOp::Overlay => "overlay".to_string(),
})
.collect::<Vec<_>>()
.join(",")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explicit_clean_wins_over_env_var() {
assert_eq!(
effective_env_mode_from(Some(SandboxEnvMode::Clean), Some("inherit")),
SandboxEnvMode::Clean
);
}
#[test]
fn explicit_inherit_wins_over_env_var() {
assert_eq!(
effective_env_mode_from(Some(SandboxEnvMode::Inherit), Some("clean")),
SandboxEnvMode::Inherit
);
}
#[test]
fn none_falls_through_to_env_var_clean() {
assert_eq!(
effective_env_mode_from(None, Some("clean")),
SandboxEnvMode::Clean
);
}
#[test]
fn none_falls_through_to_env_var_inherit() {
assert_eq!(
effective_env_mode_from(None, Some("inherit")),
SandboxEnvMode::Inherit
);
}
#[test]
fn none_no_env_var_defaults_to_inherit() {
assert_eq!(effective_env_mode_from(None, None), SandboxEnvMode::Inherit);
}
#[test]
fn none_invalid_env_var_defaults_to_inherit() {
assert_eq!(
effective_env_mode_from(None, Some("garbage")),
SandboxEnvMode::Inherit
);
}
fn parse_action_dsl(dsl_line: &str, step_index: usize) -> Action {
let input = format!("new-session\n{dsl_line}\n");
let (playbook, _) =
crate::playbook::parse_dsl::parse_dsl(&input).expect("DSL should parse");
playbook.steps[step_index].action.clone()
}
fn round_trip(action: &Action) -> (String, Action) {
let dsl = action.to_dsl();
let parsed = parse_action_dsl(&dsl, 1); (dsl, parsed)
}
#[test]
fn to_dsl_round_trip_new_session_no_name() {
let action = Action::NewSession { name: None };
let dsl = action.to_dsl();
let (playbook, _) = crate::playbook::parse_dsl::parse_dsl(&format!("{dsl}\n")).unwrap();
assert!(matches!(
playbook.steps[0].action,
Action::NewSession { name: None }
));
}
#[test]
fn to_dsl_round_trip_new_session_with_name() {
let action = Action::NewSession {
name: Some("my-session".to_string()),
};
let dsl = action.to_dsl();
let (playbook, _) = crate::playbook::parse_dsl::parse_dsl(&format!("{dsl}\n")).unwrap();
match &playbook.steps[0].action {
Action::NewSession { name } => assert_eq!(name.as_deref(), Some("my-session")),
other => panic!("expected NewSession, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_kill_session() {
let (_, parsed) = round_trip(&Action::KillSession {
name: "test".to_string(),
});
match parsed {
Action::KillSession { name } => assert_eq!(name, "test"),
other => panic!("expected KillSession, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_split_pane() {
let (_, parsed) = round_trip(&Action::SplitPane {
direction: SplitDirection::Horizontal,
ratio: None,
});
match parsed {
Action::SplitPane { direction, .. } => {
assert_eq!(direction, SplitDirection::Horizontal);
}
other => panic!("expected SplitPane, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_focus_pane() {
let (_, parsed) = round_trip(&Action::FocusPane { target: 3 });
match parsed {
Action::FocusPane { target } => assert_eq!(target, 3),
other => panic!("expected FocusPane, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_close_pane() {
let (_, parsed) = round_trip(&Action::ClosePane { target: Some(2) });
match parsed {
Action::ClosePane { target } => assert_eq!(target, Some(2)),
other => panic!("expected ClosePane, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_close_pane_no_target() {
let (_, parsed) = round_trip(&Action::ClosePane { target: None });
match parsed {
Action::ClosePane { target } => assert_eq!(target, None),
other => panic!("expected ClosePane, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_send_keys() {
let action = Action::SendKeys {
keys: b"echo hello\r".to_vec(),
pane: None,
};
let (_, parsed) = round_trip(&action);
match parsed {
Action::SendKeys { keys, pane } => {
assert_eq!(keys, b"echo hello\r");
assert_eq!(pane, None);
}
other => panic!("expected SendKeys, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_send_keys_with_pane() {
let action = Action::SendKeys {
keys: b"\x1b[A".to_vec(), pane: Some(2),
};
let (_, parsed) = round_trip(&action);
match parsed {
Action::SendKeys { keys, pane } => {
assert_eq!(keys, b"\x1b[A");
assert_eq!(pane, Some(2));
}
other => panic!("expected SendKeys, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_send_bytes() {
let action = Action::SendBytes {
hex: vec![0x1b, 0x5b, 0x41],
};
let (_, parsed) = round_trip(&action);
match parsed {
Action::SendBytes { hex } => assert_eq!(hex, vec![0x1b, 0x5b, 0x41]),
other => panic!("expected SendBytes, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_wait_for_default_timeout() {
let action = Action::WaitFor {
pattern: "hello".to_string(),
pane: None,
timeout: Duration::from_secs(5),
retry: 1,
};
let (dsl, parsed) = round_trip(&action);
assert!(
!dsl.contains("timeout="),
"default timeout should be omitted: {dsl}"
);
match parsed {
Action::WaitFor {
pattern, timeout, ..
} => {
assert_eq!(pattern, "hello");
assert_eq!(timeout, Duration::from_secs(5));
}
other => panic!("expected WaitFor, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_wait_for_custom_timeout() {
let action = Action::WaitFor {
pattern: "prompt\\$".to_string(),
pane: Some(1),
timeout: Duration::from_secs(10),
retry: 1,
};
let (dsl, parsed) = round_trip(&action);
assert!(
dsl.contains("timeout=10000"),
"custom timeout in DSL: {dsl}"
);
match parsed {
Action::WaitFor {
pattern,
pane,
timeout,
..
} => {
assert_eq!(pattern, "prompt\\$");
assert_eq!(pane, Some(1));
assert_eq!(timeout, Duration::from_secs(10));
}
other => panic!("expected WaitFor, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_sleep() {
let (_, parsed) = round_trip(&Action::Sleep {
duration: Duration::from_millis(500),
});
match parsed {
Action::Sleep { duration } => assert_eq!(duration, Duration::from_millis(500)),
other => panic!("expected Sleep, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_snapshot() {
let (_, parsed) = round_trip(&Action::Snapshot {
id: "after_echo".to_string(),
});
match parsed {
Action::Snapshot { id } => assert_eq!(id, "after_echo"),
other => panic!("expected Snapshot, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_assert_screen() {
let action = Action::AssertScreen {
pane: Some(1),
contains: Some("hello".to_string()),
not_contains: Some("error".to_string()),
matches: Some("\\d+".to_string()),
scrollback: true,
};
let (_, parsed) = round_trip(&action);
match parsed {
Action::AssertScreen {
pane,
contains,
not_contains,
matches,
scrollback,
} => {
assert_eq!(pane, Some(1));
assert_eq!(contains.as_deref(), Some("hello"));
assert_eq!(not_contains.as_deref(), Some("error"));
assert_eq!(matches.as_deref(), Some("\\d+"));
assert!(scrollback);
}
other => panic!("expected AssertScreen, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_assert_layout() {
let (_, parsed) = round_trip(&Action::AssertLayout { pane_count: 3 });
match parsed {
Action::AssertLayout { pane_count } => assert_eq!(pane_count, 3),
other => panic!("expected AssertLayout, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_assert_cursor() {
let action = Action::AssertCursor {
pane: Some(1),
row: 5,
col: 10,
};
let (_, parsed) = round_trip(&action);
match parsed {
Action::AssertCursor { pane, row, col } => {
assert_eq!(pane, Some(1));
assert_eq!(row, 5);
assert_eq!(col, 10);
}
other => panic!("expected AssertCursor, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_resize_viewport() {
let (_, parsed) = round_trip(&Action::ResizeViewport {
cols: 132,
rows: 50,
});
match parsed {
Action::ResizeViewport { cols, rows } => {
assert_eq!(cols, 132);
assert_eq!(rows, 50);
}
other => panic!("expected ResizeViewport, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_prefix_key() {
let (_, parsed) = round_trip(&Action::PrefixKey { key: 'c' });
match parsed {
Action::PrefixKey { key } => assert_eq!(key, 'c'),
other => panic!("expected PrefixKey, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_send_attach() {
let (_, parsed) = round_trip(&Action::SendAttach {
key: "ctrl+a [".to_string(),
});
match parsed {
Action::SendAttach { key } => assert_eq!(key, "ctrl+a ["),
other => panic!("expected SendAttach, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_wait_for_event() {
let action = Action::WaitForEvent {
event: "session_created".to_string(),
timeout: Duration::from_secs(5),
};
let (dsl, parsed) = round_trip(&action);
assert!(!dsl.contains("timeout="), "default timeout omitted: {dsl}");
match parsed {
Action::WaitForEvent { event, timeout } => {
assert_eq!(event, "session_created");
assert_eq!(timeout, Duration::from_secs(5));
}
other => panic!("expected WaitForEvent, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_invoke_service() {
let action = Action::InvokeService {
capability: "my.cap".to_string(),
kind: ServiceKind::Query,
interface_id: "iface.1".to_string(),
operation: "do_thing".to_string(),
payload: r#"{"key":"val"}"#.to_string(),
};
let (_, parsed) = round_trip(&action);
match parsed {
Action::InvokeService {
capability,
kind,
interface_id,
operation,
payload,
} => {
assert_eq!(capability, "my.cap");
assert_eq!(kind, ServiceKind::Query);
assert_eq!(interface_id, "iface.1");
assert_eq!(operation, "do_thing");
assert_eq!(payload, r#"{"key":"val"}"#);
}
other => panic!("expected InvokeService, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_render_mark() {
let (_, parsed) = round_trip(&Action::RenderMark {
id: "baseline".to_string(),
});
match parsed {
Action::RenderMark { id } => assert_eq!(id, "baseline"),
other => panic!("expected RenderMark, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_assert_render() {
let (_, parsed) = round_trip(&Action::AssertRender {
since: "baseline".to_string(),
assertion: RenderAssertion {
min_frames: Some(1),
max_frames: Some(2),
full_frame: Some(false),
max_rows_emitted: Some(3),
expected_emitted_rows: Some(vec![
PlaybookRenderRowRef { pane: 1, row: 0 },
PlaybookRenderRowRef { pane: 1, row: 1 },
]),
expected_emitted_row_segments: Some(vec![PlaybookRenderRowSegmentRef {
pane: 1,
row: 1,
start_col: 3,
cells: 5,
}]),
expected_trace_ops: Some(vec![
PlaybookRenderTraceOp::FullFrame,
PlaybookRenderTraceOp::PaneRowSegment {
pane: 1,
row: 1,
start_col: 3,
cells: 5,
},
PlaybookRenderTraceOp::HelpOverlay,
PlaybookRenderTraceOp::ExtensionCachedReplay { surface: 2 },
]),
..RenderAssertion::default()
},
});
match parsed {
Action::AssertRender { since, assertion } => {
assert_eq!(since, "baseline");
assert_eq!(assertion.min_frames, Some(1));
assert_eq!(assertion.max_frames, Some(2));
assert_eq!(assertion.full_frame, Some(false));
assert_eq!(assertion.max_rows_emitted, Some(3));
assert_eq!(
assertion.expected_emitted_rows,
Some(vec![
PlaybookRenderRowRef { pane: 1, row: 0 },
PlaybookRenderRowRef { pane: 1, row: 1 },
])
);
assert_eq!(
assertion.expected_emitted_row_segments,
Some(vec![PlaybookRenderRowSegmentRef {
pane: 1,
row: 1,
start_col: 3,
cells: 5,
}])
);
assert_eq!(
assertion.expected_trace_ops,
Some(vec![
PlaybookRenderTraceOp::FullFrame,
PlaybookRenderTraceOp::PaneRowSegment {
pane: 1,
row: 1,
start_col: 3,
cells: 5,
},
PlaybookRenderTraceOp::HelpOverlay,
PlaybookRenderTraceOp::ExtensionCachedReplay { surface: 2 },
])
);
}
other => panic!("expected AssertRender, got {other:?}"),
}
}
#[test]
fn to_dsl_round_trip_screen_status() {
let (_, parsed_screen) = round_trip(&Action::Screen);
assert!(matches!(parsed_screen, Action::Screen));
let (_, parsed_status) = round_trip(&Action::Status);
assert!(matches!(parsed_status, Action::Status));
}
#[test]
fn to_dsl_round_trip_wait_for_with_retry() {
let action = Action::WaitFor {
pattern: "hello".to_string(),
pane: None,
timeout: Duration::from_secs(5),
retry: 3,
};
let (dsl, parsed) = round_trip(&action);
assert!(dsl.contains("retry=3"), "DSL should contain retry=3: {dsl}");
match parsed {
Action::WaitFor { retry, .. } => assert_eq!(retry, 3),
other => panic!("expected WaitFor, got {other:?}"),
}
}
}