use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::Line;
use shep_client::RequestError;
use shep_core::config::{AppConfig, ProbeConfig, ProbeKind};
use shep_core::protocol::{
BusEvent, DogSource, Lamb, ProcessInfo, Response, RpcError, RpcErrorCode, SheepConfigView,
};
use shep_core::status::ProcStatus;
use shep_core::values::UpDuration;
use super::super::app::{
ActionVerb, App, Body, CloseDialog, Control, Effect, KeyPress, LambWalk, Msg, RevealedValue,
RowKey, Sent, SettingsRow,
};
use super::super::level::Level;
use super::super::pane::{ConfigPane, ReloadKind};
use super::super::secrets::{SecretRow, SecretsModel, Source};
use super::super::source::HostSample;
use super::super::tail::{Stream, Tail, TailLine};
use super::super::theme::Palette;
use crate::commands::settings::{DogView, ScalarView, SettingField, SettingsSnapshot};
use crate::secret_readers::Reader;
use crate::style::StyleSource;
pub const FROZEN_WHY: &str = "the shepherd did not answer: could not connect to `/home/ada/.shep/run/shep.sock`: Connection refused (os error 61)";
pub fn plain() -> Palette {
Palette::detect(None, None, None)
}
pub fn coloured() -> Palette {
Palette::detect(None, Some(OsStr::new("xterm-256color")), None)
}
pub fn app_with(flock: Vec<ProcessInfo>, palette: Palette) -> App {
let t0 = Instant::now();
let mut app = App::new(
palette,
Control::ReadOnly,
"/home/ada/.shep".to_string(),
t0,
);
app.update(Msg::Snapshot {
rows: flock,
at: t0,
});
app
}
pub fn sheep_in_fold(id: u32, name: &str, fold: Option<&str>) -> ProcessInfo {
ProcessInfo::builder(id, name, ProcStatus::Online)
.pid(Some(1000 + id))
.uptime_ms(60_000)
.fold(fold.map(str::to_string))
.build()
}
#[must_use]
pub fn sheep_in_fold_with_status(
id: u32,
name: &str,
fold: Option<&str>,
status: ProcStatus,
) -> ProcessInfo {
ProcessInfo::builder(id, name, status)
.pid(Some(1000 + id))
.uptime_ms(60_000)
.fold(fold.map(str::to_string))
.build()
}
pub fn sheep_with(
id: u32,
name: &str,
fold: Option<&str>,
uptime_ms: u64,
memory: Option<u64>,
restarts: u32,
) -> ProcessInfo {
ProcessInfo::builder(id, name, ProcStatus::Online)
.pid(Some(1000 + id))
.uptime_ms(uptime_ms)
.memory_bytes(memory)
.restarts(restarts)
.fold(fold.map(str::to_string))
.build()
}
pub fn instance_in_fold(id: u32, name: &str, slot: u32, fold: Option<&str>) -> ProcessInfo {
ProcessInfo::builder(id, name, ProcStatus::Online)
.pid(Some(1000 + id))
.uptime_ms(60_000)
.instance(Some(slot))
.fold(fold.map(str::to_string))
.build()
}
pub fn flock_of(count: u32, with_readings: u32) -> Vec<ProcessInfo> {
(0..count)
.map(|id| {
let reports = id < with_readings;
ProcessInfo::builder(id, format!("sheep-{id}"), ProcStatus::Online)
.pid(Some(48_000 + id))
.uptime_ms(4_512_000)
.cpu_percent(reports.then_some(3.5))
.memory_bytes(reports.then_some(182 << 20))
.out_file(Some(format!("/home/ada/.shep/logs/sheep-{id}-out.log")))
.err_file(Some(format!("/home/ada/.shep/logs/sheep-{id}-err.log")))
.build()
})
.collect()
}
pub fn app_with_a_dog() -> App {
let flock = vec![
ProcessInfo::builder(1, "web", ProcStatus::Online)
.pid(Some(48_001))
.uptime_ms(4_512_000)
.build(),
ProcessInfo::builder(2, "api", ProcStatus::Online)
.pid(Some(48_002))
.uptime_ms(4_512_000)
.build(),
ProcessInfo::builder(90, "otel", ProcStatus::Online)
.pid(Some(90_000))
.dog(Some(DogSource::BuiltIn))
.build(),
];
app_with(flock, plain())
}
pub fn app_with_a_dog_selected_and_control() -> App {
let decoy = ProcessInfo::builder(0, "!decoy", ProcStatus::Online).build();
let dog = ProcessInfo::builder(90, "otel", ProcStatus::Online)
.pid(Some(90_000))
.dog(Some(DogSource::Adopted {
path: "/opt/otel".to_string(),
}))
.build();
let mut app = app_with(vec![decoy, dog], plain());
app.set_control_for_tests(Control::Allowed);
app.update(Msg::Key(KeyPress::SelectDown));
app
}
pub fn app_with_a_built_in_dog_selected_and_control() -> App {
let decoy = ProcessInfo::builder(0, "!decoy", ProcStatus::Online).build();
let dog = ProcessInfo::builder(90, "otel", ProcStatus::Online)
.pid(Some(90_000))
.dog(Some(DogSource::BuiltIn))
.build();
let mut app = app_with(vec![decoy, dog], plain());
app.set_control_for_tests(Control::Allowed);
app.update(Msg::Key(KeyPress::SelectDown));
app
}
pub fn sample() -> HostSample {
HostSample {
load: (2.31, 4.10, 3.88),
cores: Some(10),
memory_total_bytes: 32 << 30,
memory_used_bytes: 12 * (1 << 30) + (410 << 20),
uptime_seconds: 6 * 86_400 + 3 * 3_600,
}
}
pub fn with_host(sample: HostSample, flock: Vec<ProcessInfo>) -> App {
let mut app = app_with(flock, plain());
app.update(Msg::Host {
sample: Some(sample),
});
app
}
pub fn with_host_none(flock: Vec<ProcessInfo>, unsupported: bool) -> App {
let mut app = app_with(flock, plain());
if unsupported {
app.update(Msg::Host { sample: None });
}
app
}
pub fn with_feed(tail: Tail) -> App {
let mut app = app_with(flock_of(3, 0), plain());
app.update(Msg::Bleats { tail });
app
}
pub fn with_feed_and_selection(tail: Tail, id: u32) -> App {
let mut app = app_with(flock_of(3, 0), plain());
for _ in 0..id {
app.update(Msg::Key(KeyPress::SelectDown));
}
app.update(Msg::Bleats { tail });
app
}
pub fn with_feed_and_palette(tail: Tail, palette: Palette) -> App {
let mut app = app_with(flock_of(3, 0), palette);
app.update(Msg::Bleats { tail });
app
}
pub fn with_no_selection() -> App {
app_with(Vec::new(), plain())
}
pub fn with_selection(info: ProcessInfo) -> App {
with_selection_and_palette(info, plain())
}
pub fn with_selection_and_palette(info: ProcessInfo, palette: Palette) -> App {
assert!(
info.id > 0,
"the decoy takes id 0, so the sheep under test cannot"
);
let wanted = info.id;
let decoy = ProcessInfo::builder(0, "!decoy", ProcStatus::Online).build();
let mut app = app_with(vec![decoy, info], palette);
app.update(Msg::Key(KeyPress::SelectDown));
assert_eq!(
app.selected(),
Some(RowKey::Sheep(wanted)),
"the sheep under test must end up selected, and on row 1: the mutation \
this fixture exists to catch reads row 0 instead"
);
app
}
pub fn sheep_with_lambs() -> ProcessInfo {
ProcessInfo::builder(9, "gateway", ProcStatus::Online)
.pid(Some(48_301))
.lambs(Some(vec![
Lamb::new(48_302, "node"),
Lamb::new(48_303, "sh"),
]))
.build()
}
pub fn with_lamb_reading(walk: LambWalk) -> App {
with_lamb_reading_for(9, walk)
}
pub fn with_lamb_reading_for(id: u32, walk: LambWalk) -> App {
let mut app = with_selection(sheep_with_lambs());
app.update(Msg::Replied {
sent: Sent::Lambs { id },
result: reply_for(id, &walk),
});
app
}
pub fn app_with_lamb_reading_at(walk: LambWalk) -> (App, Instant) {
let t0 = Instant::now();
let mut app = with_selection(sheep_with_lambs());
app.update(Msg::Tick { now: t0 });
app.update(Msg::Replied {
sent: Sent::Lambs { id: 9 },
result: reply_for(9, &walk),
});
(app, t0)
}
fn reply_for(id: u32, walk: &LambWalk) -> Result<Response, RequestError> {
let lambs = match walk {
LambWalk::Failed => return Err(RequestError::Closed),
LambWalk::NotWalked => None,
LambWalk::Walked(lambs) => Some(lambs.clone()),
};
Ok(Response::Described(vec![
ProcessInfo::builder(id, "gateway", ProcStatus::Online)
.pid(Some(48_301))
.lambs(lambs)
.build(),
]))
}
pub fn lamb_line_of(app: &App) -> String {
render_all(&super::detail::detail_lines(app, 200))
.lines()
.find(|line| line.starts_with("lambs "))
.map(str::to_string)
.expect("the pane has a lamb line")
}
pub fn full_app() -> App {
let mut app = app_with(flock_of(12, 12), plain());
app.update(Msg::Bleats {
tail: Tail {
lines: (0..10)
.map(|n| line(Stream::Out, &format!("line-{n}")))
.collect(),
missed_lines: 0,
missed_bytes: 0,
read_bytes: 1_024,
note: None,
},
});
app
}
pub fn bleats_pane_with_filters() -> App {
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
app.update(Msg::Bleats {
tail: Tail {
lines: vec![
line(Stream::Err, "ERROR pool exhausted"), line(Stream::Out, "ERROR pool exhausted"), line(Stream::Err, "INFO pool warming"), line(Stream::Err, "ERROR disk full"), ],
missed_lines: 0,
missed_bytes: 0,
read_bytes: 128,
note: None,
},
});
app.update(Msg::Key(KeyPress::Bleats));
let pane = app
.bleats_pane_mut_for_tests()
.expect("Msg::Key(KeyPress::Bleats) opened the pane on the sheep selected above");
pane.set_stream(Some(Stream::Err));
pane.set_min_level(Some(Level::Warn));
pane.set_match("pool".to_string());
app
}
pub fn bleats_pane_with_lines(n: u32) -> App {
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
app.update(Msg::Bleats {
tail: Tail {
lines: (0..n)
.map(|i| line(Stream::Out, &format!("line-{i}")))
.collect(),
missed_lines: 0,
missed_bytes: 0,
read_bytes: 1_024,
note: None,
},
});
app.update(Msg::Key(KeyPress::Bleats));
app
}
#[must_use]
pub fn bleats_pane_with_mixed_line_lengths() -> App {
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
let mut lines: Vec<TailLine> = (0..40)
.map(|i| {
let padding = "y".repeat(50 * (i % 4));
line(Stream::Out, &format!("old-{i} {padding}"))
})
.collect();
lines.extend((0..40).map(|i| line(Stream::Out, &format!("new-{i}"))));
app.update(Msg::Bleats {
tail: Tail {
lines,
missed_lines: 0,
missed_bytes: 0,
read_bytes: 1_024,
note: None,
},
});
app.update(Msg::Key(KeyPress::Bleats));
app
}
#[must_use]
pub fn bleats_pane_with_a_wide_line() -> App {
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
app.update(Msg::Bleats {
tail: Tail {
lines: vec![line(Stream::Out, &"\u{5e83}".repeat(60))],
missed_lines: 0,
missed_bytes: 0,
read_bytes: 1_024,
note: None,
},
});
app.update(Msg::Key(KeyPress::Bleats));
app
}
pub fn bleats_pane_with_long_line() -> App {
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
app.update(Msg::Bleats {
tail: Tail {
lines: vec![
line(Stream::Out, "short line"),
line(Stream::Out, &"x".repeat(200)),
],
missed_lines: 0,
missed_bytes: 0,
read_bytes: 1_024,
note: None,
},
});
app.update(Msg::Key(KeyPress::Bleats));
app
}
pub fn draw_lines(app: &App, width: u16, rows: usize) -> Vec<Line<'static>> {
super::bleats_full::draw_lines(app, width, rows)
}
pub fn app_fixture() -> App {
let dir = tempfile::Builder::new()
.prefix("shep-fx-")
.tempdir()
.expect("a tempdir for the fixture's logs");
let out_path = dir.path().join("catcher-out.log");
let err_path = dir.path().join("catcher-err.log");
std::fs::write(&out_path, b"listening on :8080\n").expect("write the out log");
std::fs::write(&err_path, b"warn: retrying upstream\n").expect("write the err log");
let _ = dir.keep();
let info = ProcessInfo::builder(7, "catcher", ProcStatus::Online)
.pid(Some(48_107))
.uptime_ms(4_512_000)
.out_file(Some(out_path.display().to_string()))
.err_file(Some(err_path.display().to_string()))
.build();
let mut app = with_selection(info);
app.update(Msg::Bleats {
tail: Tail {
lines: vec![
line(Stream::Out, "listening on :8080"),
line(Stream::Err, "warn: retrying upstream"),
],
missed_lines: 0,
missed_bytes: 0,
read_bytes: 1_024,
note: None,
},
});
app
}
pub fn line(stream: Stream, text: &str) -> TailLine {
TailLine {
stream,
text: text.to_string(),
}
}
pub fn rendered(line: &Line<'static>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
pub fn render_all(lines: &[Line<'static>]) -> String {
lines.iter().map(rendered).collect::<Vec<_>>().join("\n")
}
pub fn filtered_app(query: &str) -> App {
filtered_app_of(named_flock(), query)
}
pub fn filtered_app_of(flock: Vec<ProcessInfo>, query: &str) -> App {
let mut app = app_with(flock, plain());
if !query.is_empty() {
app.update(Msg::Key(KeyPress::FilterStart));
for typed in query.chars() {
app.update(Msg::Key(KeyPress::TextChar(typed)));
}
app.update(Msg::Key(KeyPress::TextApply));
}
app
}
pub fn editing_app(query: &str) -> App {
let mut app = app_with(named_flock(), plain());
app.update(Msg::Key(KeyPress::FilterStart));
for typed in query.chars() {
app.update(Msg::Key(KeyPress::TextChar(typed)));
}
app
}
fn named_flock() -> Vec<ProcessInfo> {
[(1, "web"), (2, "api"), (3, "web-worker"), (4, "cron")]
.into_iter()
.map(|(id, name)| {
ProcessInfo::builder(id, name, ProcStatus::Online)
.pid(Some(48_000 + id))
.uptime_ms(4_512_000)
.build()
})
.collect()
}
pub fn allowed_app() -> App {
let mut app = app_with(named_flock(), plain());
app.set_control_for_tests(Control::Allowed);
for _ in 0..named_flock().len() {
if app.selected() == Some(RowKey::Sheep(2)) {
break;
}
app.update(Msg::Key(KeyPress::SelectDown));
}
assert_eq!(
app.selected(),
Some(RowKey::Sheep(2)),
"the cursor must end up on api"
);
app
}
pub fn armed_app(verb: ActionVerb) -> App {
let mut app = allowed_app();
app.update(Msg::Key(KeyPress::Action(verb)));
app
}
pub fn acting_app(verb: ActionVerb) -> App {
let mut app = armed_app(verb);
app.update(Msg::Key(KeyPress::Confirm));
app
}
pub fn armed_app_with_a_filter_and_a_notice() -> App {
let mut app = filtered_app("api");
app.set_control_for_tests(Control::Allowed);
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
app.update(Msg::Event(BusEvent::Dropped { count: 3 }));
app
}
pub fn settings_snapshot() -> SettingsSnapshot {
let config = |value: &str| ScalarView {
value: value.to_string(),
source: StyleSource::Config,
};
SettingsSnapshot {
log_level: config("warn"),
log_json: config("false"),
socket: config("/home/ada/.shep/run/shep.sock"),
max_cron_sleep: config("30s"),
allow_control: config("false"),
style_level: config("full"),
style_level_in_file: Some("full".to_string()),
dogs: vec![
DogView {
name: "bark".to_string(),
enabled: false,
adopted_path: None,
},
DogView {
name: "metrics".to_string(),
enabled: true,
adopted_path: None,
},
],
}
}
pub fn app_in_settings() -> App {
let mut app = app_with(flock_of(3, 0), plain());
app.update(Msg::Key(KeyPress::Settings));
app.update(Msg::Settings {
result: Ok(settings_snapshot()),
});
app
}
pub fn app_in_settings_on(field: SettingField) -> App {
let mut app = app_in_settings_with_control();
let target = app
.settings()
.unwrap()
.rows()
.iter()
.position(|row| *row == SettingsRow::Scalar(field))
.expect("field is one of the six scalar rows Settings::rows always carries");
for _ in 0..target {
app.update(Msg::Key(KeyPress::SelectDown));
}
app
}
pub fn app_in_settings_at() -> (App, Instant) {
let t0 = Instant::now();
let mut app = App::new(plain(), Control::Allowed, "/home/ada/.shep".to_string(), t0);
app.update(Msg::Key(KeyPress::Settings));
app.update(Msg::Settings {
result: Ok(settings_snapshot()),
});
(app, t0)
}
pub fn app_in_settings_with_shadowed_style(source: StyleSource) -> App {
let mut app = app_in_settings_on(SettingField::StyleLevel);
let mut snapshot = settings_snapshot();
snapshot.style_level = ScalarView {
value: "bare".to_string(),
source,
};
snapshot.style_level_in_file = Some("full".to_string());
app.update(Msg::Settings {
result: Ok(snapshot),
});
app
}
pub fn app_in_settings_with_control() -> App {
let mut app = app_in_settings();
app.set_control_for_tests(Control::Allowed);
app
}
fn settings_snapshot_with_dogs(dogs: Vec<DogView>) -> SettingsSnapshot {
SettingsSnapshot {
dogs,
..settings_snapshot()
}
}
pub fn app_in_settings_with_dog_drift() -> App {
let flock = vec![
ProcessInfo::builder(90, "otel", ProcStatus::Online)
.pid(Some(90_000))
.dog(Some(DogSource::BuiltIn))
.build(),
];
let mut app = app_with(flock, plain());
app.update(Msg::Key(KeyPress::Settings));
app.update(Msg::Settings {
result: Ok(settings_snapshot_with_dogs(vec![
DogView {
name: "otel".to_string(),
enabled: false,
adopted_path: Some(PathBuf::from("/usr/local/bin/shep-otel")),
},
DogView {
name: "ledger".to_string(),
enabled: true,
adopted_path: Some(PathBuf::from("/opt/ledger/bin/dog")),
},
])),
});
app
}
pub fn app_in_settings_with_silent_dog() -> App {
let flock = vec![
ProcessInfo::builder(91, "bark", ProcStatus::Online)
.pid(Some(91_000))
.dog(Some(DogSource::BuiltIn))
.handshook(Some(false))
.build(),
];
let mut app = app_with(flock, plain());
app.update(Msg::Key(KeyPress::Settings));
app.update(Msg::Settings {
result: Ok(settings_snapshot_with_dogs(vec![DogView {
name: "bark".to_string(),
enabled: true,
adopted_path: None,
}])),
});
app
}
fn settings_snapshot_for_toggle_tests() -> SettingsSnapshot {
settings_snapshot_with_dogs(vec![
DogView {
name: "metrics".to_string(),
enabled: false,
adopted_path: None,
},
DogView {
name: "otel".to_string(),
enabled: true,
adopted_path: Some(PathBuf::from("/usr/local/bin/shep-otel")),
},
])
}
pub fn app_in_settings_on_dog(name: &str) -> App {
let mut app = app_with(flock_of(3, 0), plain());
app.set_control_for_tests(Control::Allowed);
app.update(Msg::Key(KeyPress::Settings));
app.update(Msg::Settings {
result: Ok(settings_snapshot_for_toggle_tests()),
});
let dog_index = settings_snapshot_for_toggle_tests()
.dogs
.iter()
.position(|dog| dog.name == name)
.expect("name is one of settings_snapshot_for_toggle_tests's own dogs");
for _ in 0..(6 + dog_index) {
app.update(Msg::Key(KeyPress::SelectDown));
}
app
}
pub fn app_in_settings_on_enabled_dog(name: &str) -> App {
app_in_settings_on_dog(name)
}
pub fn sheep_config_view() -> SheepConfigView {
sheep_config_view_parking(vec!["kill_signal".to_string()])
}
fn sheep_config_view_parking(pending: Vec<String>) -> SheepConfigView {
let mut config = AppConfig {
name: "web".to_string(),
script: "./srv".to_string(),
args: vec!["--port".to_string(), "8080".to_string()],
max_restarts: 32,
instances: 3,
..AppConfig::default()
};
config
.env
.insert("DB_HOST".to_string(), "db.internal".to_string());
config
.env
.insert("LOG_LEVEL".to_string(), "debug".to_string());
SheepConfigView::new(
config,
vec!["max_restarts".to_string(), "reuse_port".to_string()],
pending,
)
}
fn close_dialog_pane(
wait_ready: bool,
has_probe: bool,
reuse_port: bool,
instances: u32,
) -> ConfigPane {
let config = AppConfig {
name: "web".to_string(),
kill_timeout: UpDuration::from_millis(5_000),
graceful_timeout: UpDuration::from_millis(10_000),
wait_ready,
reuse_port,
instances,
readiness_probe: has_probe.then(|| ProbeConfig {
kind: ProbeKind::Tcp,
target: "127.0.0.1:8080".into(),
interval: UpDuration::from_millis(10_000),
timeout: UpDuration::from_millis(5_000),
failure_threshold: 3,
}),
..AppConfig::default()
};
ConfigPane::sheep(SheepConfigView::new(config, Vec::new(), Vec::new()))
}
const DIALOG_PID: u32 = 71_578;
pub fn close_dialog_with(unsent: usize, parked: usize) -> CloseDialog {
let pane = close_dialog_pane(true, false, false, 1);
let unsent_fields = (0..unsent).map(|i| format!("field{i}")).collect();
CloseDialog::new(
unsent_fields,
parked,
&pane,
ProcStatus::Online,
Some(DIALOG_PID),
Instant::now(),
)
}
pub fn close_dialog_without_a_pid() -> CloseDialog {
let pane = close_dialog_pane(true, false, false, 2);
CloseDialog::new(
vec!["cwd".to_string()],
0,
&pane,
ProcStatus::Online,
None,
Instant::now(),
)
}
pub fn close_dialog_reloading(kind: ReloadKind, instances: u32) -> CloseDialog {
let (wait_ready, has_probe, reuse_port) = match kind {
ReloadKind::Overlap => (true, false, false),
ReloadKind::Serial => (false, true, false),
};
let pane = close_dialog_pane(wait_ready, has_probe, reuse_port, instances);
CloseDialog::new(
vec!["cwd".to_string()],
0,
&pane,
ProcStatus::Online,
Some(DIALOG_PID),
Instant::now(),
)
}
pub fn close_dialog_with_live_edit(with_live: bool) -> CloseDialog {
let mut app = app_in_sheep_pane_with_nothing_parked();
file_edit(&mut app, "cwd", "/srv/app");
if with_live {
file_edit(&mut app, "max_restarts", "9");
}
let pane = app.config_pane().expect("the pane is open");
CloseDialog::new(
pane.unsent_fields_needing_a_respawn(),
pane.parked_count(),
pane,
ProcStatus::Online,
Some(DIALOG_PID),
Instant::now(),
)
}
#[track_caller]
pub fn row_starting_with(lines: &[Line<'static>], prefix: &str) -> String {
lines
.iter()
.map(rendered)
.find(|line| line.trim_start().starts_with(prefix))
.unwrap_or_else(|| panic!("no row starts with {prefix:?}"))
}
fn app_with_close_dialog_and_palette(palette: Palette) -> App {
let mut app = with_selection_and_palette(
ProcessInfo::builder(9, "web", ProcStatus::Online)
.pid(Some(48_000))
.build(),
palette,
);
app.set_control_for_tests(Control::Allowed);
app.update(Msg::Key(KeyPress::Edit));
app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "web".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(sheep_config_view_parking(
Vec::new(),
)))),
});
file_edit(&mut app, "cwd", "/srv/app");
app.update(Msg::Key(KeyPress::Escape));
assert!(
app.close_dialog().is_some(),
"close_offer refused to raise a dialog"
);
app
}
pub fn app_with_close_dialog() -> App {
app_with_close_dialog_and_palette(plain())
}
pub fn render_dialog(width: u16, height: u16) -> Buffer {
render(&app_with_close_dialog(), width, height)
}
#[track_caller]
pub fn row_containing(buffer: &Buffer, needle: &str) -> String {
rows_of(buffer)
.into_iter()
.find(|row| row.contains(needle))
.unwrap_or_else(|| panic!("no row contains {needle:?}"))
}
pub fn draw_pane_with_dialog(width: u16, height: u16) -> Buffer {
draw_pane_with_dialog_and_palette(width, height, plain())
}
pub fn draw_pane_with_dialog_and_palette(width: u16, height: u16, palette: Palette) -> Buffer {
let app = app_with_close_dialog_and_palette(palette);
let area = Rect::new(0, 0, width, height);
let mut buffer = Buffer::empty(area);
let pane = app.config_pane().expect("the pane is open");
super::pane::draw_pane(&app, pane, area, &mut buffer);
buffer
}
pub fn no_color() -> Palette {
Palette::detect(Some(OsStr::new("1")), None, None)
}
pub fn plain_dimmed() -> Style {
Style::reset().patch(plain().muted())
}
pub fn dog_section() -> String {
"# how often\npoll = \"60s\"\nhistory_bytes = 4096\n\n[sinks.ops]\nkind = \"slack\"\nurl = \"https://hooks.example/x\"\n"
.to_string()
}
pub fn app_in_dog_pane() -> App {
let mut app = app_with(flock_of(3, 0), plain());
app.set_control_for_tests(Control::Allowed);
app.update(Msg::Key(KeyPress::Settings));
app.update(Msg::Settings {
result: Ok(settings_snapshot()),
});
for _ in 0..6 {
app.update(Msg::Key(KeyPress::SelectDown));
}
app.update(Msg::Key(KeyPress::Edit));
app.update(Msg::DogPane {
name: "bark".to_string(),
adopted_path: None,
result: Ok(crate::dog::builtin_schema("bark").expect("bark is a built-in")),
});
app.update(Msg::Replied {
sent: Sent::DogSection {
name: "bark".to_string(),
},
result: Ok(Response::DogSection {
toml: dog_section().into(),
}),
});
app
}
pub fn app_in_dog_pane_with_two_edits() -> App {
let mut app = app_in_dog_pane();
for (key, typed) in [("poll", "45s"), ("history_bytes", "8192")] {
select_field(&mut app, key);
app.update(Msg::Key(KeyPress::Confirm));
for _ in 0..64 {
app.update(Msg::Key(KeyPress::TextBackspace));
}
for character in typed.chars() {
app.update(Msg::Key(KeyPress::TextChar(character)));
}
app.update(Msg::Key(KeyPress::TextApply));
}
assert_eq!(
app.config_pane().expect("the pane is open").edits().len(),
2,
"the fixture files two edits"
);
app
}
pub fn app_in_sheep_pane_with_env(env: &[(&str, &str)]) -> App {
let mut app = with_selection(
ProcessInfo::builder(9, "web", ProcStatus::Online)
.pid(Some(48_000))
.build(),
);
app.set_control_for_tests(Control::Allowed);
app.update(Msg::Key(KeyPress::Edit));
let mut config = AppConfig {
name: "web".to_string(),
..AppConfig::default()
};
for (key, value) in env {
config.env.insert((*key).to_string(), (*value).to_string());
}
app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "web".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(SheepConfigView::new(
config,
Vec::new(),
Vec::new(),
)))),
});
app
}
#[track_caller]
pub fn select_env_key(app: &mut App, key: &str) {
let pane = app.config_pane().expect("the pane is open");
let index = pane
.rows()
.iter()
.position(|row| match row {
crate::lookout::pane::PaneRow::Env(env_index) => {
pane.env_key_names().get(*env_index).map(String::as_str) == Some(key)
}
crate::lookout::pane::PaneRow::Field(_) | crate::lookout::pane::PaneRow::AddEnv => {
false
}
})
.unwrap_or_else(|| panic!("no env key named {key}"));
app.update(Msg::Key(KeyPress::SelectFirst));
for _ in 0..index {
app.update(Msg::Key(KeyPress::SelectDown));
}
}
pub fn type_into_the_open_editor(app: &mut App, text: &str) {
app.update(Msg::Key(KeyPress::Confirm));
for character in text.chars() {
app.update(Msg::Key(KeyPress::TextChar(character)));
}
app.update(Msg::Key(KeyPress::TextApply));
}
pub fn app_in_sheep_pane_with_control() -> App {
let mut app = with_selection(
ProcessInfo::builder(9, "web", ProcStatus::Online)
.pid(Some(48_000))
.build(),
);
app.set_control_for_tests(Control::Allowed);
app.update(Msg::Key(KeyPress::Edit));
app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "web".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(sheep_config_view()))),
});
app
}
pub fn app_in_sheep_pane_with_a_parked_field() -> App {
app_in_sheep_pane_with_control()
}
pub fn app_in_sheep_pane_with_two_parked_fields() -> App {
app_in_sheep_pane_parking(vec!["kill_signal".to_string(), "script".to_string()])
}
pub fn app_in_sheep_pane_with_nothing_parked() -> App {
app_in_sheep_pane_parking(Vec::new())
}
fn app_in_sheep_pane_parking(pending: Vec<String>) -> App {
let mut app = with_selection(
ProcessInfo::builder(9, "web", ProcStatus::Online)
.pid(Some(48_000))
.build(),
);
app.set_control_for_tests(Control::Allowed);
app.update(Msg::Key(KeyPress::Edit));
app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "web".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(sheep_config_view_parking(
pending,
)))),
});
app
}
pub fn app_in_sheep_pane() -> App {
let mut app = with_selection(
ProcessInfo::builder(9, "web", ProcStatus::Online)
.pid(Some(48_000))
.build(),
);
app.update(Msg::Key(KeyPress::Edit));
app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "web".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(sheep_config_view()))),
});
app
}
pub fn app_in_sheep_pane_read_only() -> App {
let mut app = app_in_sheep_pane();
app.set_control_for_tests(Control::ReadOnly);
app
}
pub fn app_in_sheep_pane_on_a_stopped_sheep() -> App {
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Stopped).build());
app.set_control_for_tests(Control::Allowed);
app.update(Msg::Key(KeyPress::Edit));
app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "web".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(sheep_config_view_parking(
Vec::new(),
)))),
});
app
}
pub fn app_in_sheep_pane_on_a_draining_sheep() -> App {
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Stopping).build());
app.set_control_for_tests(Control::Allowed);
app.update(Msg::Key(KeyPress::Edit));
app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "web".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(sheep_config_view_parking(
Vec::new(),
)))),
});
app
}
pub fn file_edit(app: &mut App, key: &str, value: &str) {
app.set_control_for_tests(Control::Allowed);
select_field(app, key);
app.update(Msg::Key(KeyPress::Confirm));
for _ in 0..64 {
app.update(Msg::Key(KeyPress::TextBackspace));
}
for character in value.chars() {
app.update(Msg::Key(KeyPress::TextChar(character)));
}
app.update(Msg::Key(KeyPress::TextApply));
}
pub fn render(app: &App, width: u16, height: u16) -> Buffer {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal.draw(|frame| super::draw(app, frame)).unwrap();
terminal.backend().buffer().clone()
}
pub fn rows_of(buffer: &Buffer) -> Vec<String> {
crate::lookout::frames::render_text(buffer)
.lines()
.map(str::to_string)
.collect()
}
pub fn app_with_secrets() -> App {
let mut app = full_app();
app.update(Msg::Key(KeyPress::Secrets));
app.update(Msg::Secrets {
environment: "production".to_string(),
result: Ok(Box::new(SecretsModel {
environments: vec![
"all".to_string(),
"ci".to_string(),
"production".to_string(),
],
rows: vec![
SecretRow {
key: "DB_PASSWORD".to_string(),
source: Source::Operator,
in_force: Some("production".to_string()),
set_in: vec!["production".to_string()],
byte_len: Some(9),
readers: Vec::new(),
},
SecretRow {
key: "ELSEWHERE_ONLY".to_string(),
source: Source::Operator,
in_force: None,
set_in: vec!["ci".to_string()],
byte_len: None,
readers: Vec::new(),
},
SecretRow {
key: "SET_EVERYWHERE".to_string(),
source: Source::Operator,
in_force: Some("all".to_string()),
set_in: vec!["all".to_string()],
byte_len: Some(4),
readers: Vec::new(),
},
SecretRow {
key: "SET_IN_ALL_THREE".to_string(),
source: Source::Operator,
in_force: Some("all".to_string()),
set_in: vec![
"all".to_string(),
"ci".to_string(),
"production".to_string(),
],
byte_len: Some(4),
readers: Vec::new(),
},
],
..SecretsModel::default()
})),
});
app
}
pub fn app_with_a_maximum_length_secret() -> App {
let mut app = full_app();
app.update(Msg::Key(KeyPress::Secrets));
app.update(Msg::Secrets {
environment: "production".to_string(),
result: Ok(Box::new(SecretsModel {
environments: vec!["production".to_string()],
rows: vec![SecretRow {
key: "HUGE".to_string(),
source: Source::Operator,
in_force: Some("production".to_string()),
set_in: vec!["production".to_string()],
byte_len: Some(shep_core::secrets::MAX_VALUE_BYTES),
readers: Vec::new(),
}],
..SecretsModel::default()
})),
});
app
}
pub fn app_with_a_pushed_secret() -> App {
let mut app = full_app();
app.update(Msg::Key(KeyPress::Secrets));
app.update(Msg::Secrets {
environment: "production".to_string(),
result: Ok(Box::new(SecretsModel {
environments: vec!["production".to_string()],
rows: vec![SecretRow {
key: "vercel/API_TOKEN".to_string(),
source: Source::Namespace("vercel".to_string()),
in_force: Some("production".to_string()),
set_in: vec!["production".to_string()],
byte_len: Some(6),
readers: Vec::new(),
}],
..SecretsModel::default()
})),
});
app
}
pub fn app_with_secrets_and_a_provider_row() -> App {
let mut app = full_app();
app.update(Msg::Key(KeyPress::Secrets));
app.update(Msg::Secrets {
environment: "production".to_string(),
result: Ok(Box::new(SecretsModel {
environments: vec!["production".to_string()],
rows: vec![
SecretRow {
key: "DB_PASSWORD".to_string(),
source: Source::Operator,
in_force: Some("production".to_string()),
set_in: vec!["production".to_string()],
byte_len: Some(9),
readers: Vec::new(),
},
SecretRow {
key: "vercel/API_TOKEN".to_string(),
source: Source::Namespace("vercel".to_string()),
in_force: Some("production".to_string()),
set_in: vec!["production".to_string()],
byte_len: Some(6),
readers: Vec::new(),
},
],
..SecretsModel::default()
})),
});
app
}
pub fn app_with_interleaved_secret_sources() -> App {
let mut app = full_app();
app.update(Msg::Key(KeyPress::Secrets));
app.update(Msg::Secrets {
environment: "production".to_string(),
result: Ok(Box::new(SecretsModel {
environments: vec!["production".to_string()],
rows: vec![
SecretRow {
key: "FIRST_OPERATOR_KEY".to_string(),
source: Source::Operator,
in_force: Some("production".to_string()),
set_in: vec!["production".to_string()],
byte_len: Some(1),
readers: Vec::new(),
},
SecretRow {
key: "vercel/API_TOKEN".to_string(),
source: Source::Namespace("vercel".to_string()),
in_force: Some("production".to_string()),
set_in: vec!["production".to_string()],
byte_len: Some(6),
readers: Vec::new(),
},
SecretRow {
key: "SECOND_OPERATOR_KEY".to_string(),
source: Source::Operator,
in_force: Some("production".to_string()),
set_in: vec!["production".to_string()],
byte_len: Some(2),
readers: Vec::new(),
},
],
..SecretsModel::default()
})),
});
app
}
pub const REVEALED_VALUE: &str = "hunter2-not-really";
pub fn app_with_secrets_and_reads(home: &Path, allow_read: bool) -> App {
let mut app = full_app();
app.update(Msg::Key(KeyPress::Secrets));
app.update(Msg::Secrets {
environment: "production".to_string(),
result: Ok(Box::new(secrets_model(home, allow_read))),
});
app
}
pub fn secrets_model(home: &Path, allow_read: bool) -> SecretsModel {
let store = home.join("secrets.json");
shep_core::secrets::set(&store, "DB_PASSWORD", "production", REVEALED_VALUE).unwrap();
SecretsModel {
environments: vec!["all".to_string(), "production".to_string()],
rows: vec![
SecretRow {
key: "DB_PASSWORD".to_string(),
source: Source::Operator,
in_force: Some("production".to_string()),
set_in: vec!["production".to_string()],
byte_len: Some(REVEALED_VALUE.len()),
readers: Vec::new(),
},
SecretRow {
key: "OTHER_KEY".to_string(),
source: Source::Operator,
in_force: Some("production".to_string()),
set_in: vec!["production".to_string()],
byte_len: Some(3),
readers: Vec::new(),
},
],
allow_read,
store,
..SecretsModel::default()
}
}
fn secrets_write_model() -> SecretsModel {
SecretsModel {
environments: vec![
"all".to_string(),
"ci".to_string(),
"production".to_string(),
],
rows: vec![SecretRow {
key: "DB_PASSWORD".to_string(),
source: Source::Operator,
in_force: Some("all".to_string()),
set_in: vec!["all".to_string()],
byte_len: Some(9),
readers: Vec::new(),
}],
..SecretsModel::default()
}
}
pub fn app_with_secrets_read_only() -> App {
let mut app = full_app();
app.update(Msg::Key(KeyPress::Secrets));
app.update(Msg::Secrets {
environment: "all".to_string(),
result: Ok(Box::new(secrets_write_model())),
});
app
}
pub fn app_with_secrets_and_control() -> App {
let mut app = app_with_secrets_read_only();
app.set_control_for_tests(Control::Allowed);
app
}
pub fn app_with_secrets_on_a_named_tab_and_control() -> App {
let mut app = app_with_secrets();
app.set_control_for_tests(Control::Allowed);
app
}
pub fn app_typing_a_value() -> App {
let mut app = app_with_secrets_and_control();
app.update(Msg::Key(KeyPress::Confirm));
app
}
pub fn app_armed_to_delete_a_secret() -> App {
let mut app = app_with_secrets_and_control();
app.update(Msg::Key(KeyPress::SecretDelete));
app
}
pub fn app_typing_a_new_key() -> App {
let mut app = app_with_secrets_and_control();
app.update(Msg::Key(KeyPress::SelectLast));
app.update(Msg::Key(KeyPress::Confirm));
app
}
pub fn app_with_a_pushed_secret_selected_and_control() -> App {
let mut app = app_with_a_pushed_secret();
app.set_control_for_tests(Control::Allowed);
app
}
pub fn app_revealing_with_control() -> App {
let dir = tempfile::tempdir().unwrap();
let mut app = app_revealing(dir.path());
app.set_control_for_tests(Control::Allowed);
let _ = dir.keep();
app
}
#[track_caller]
pub fn ask_to_reveal(app: &mut App) -> Msg {
let Effect::RevealSecret {
store,
provider_cache,
row,
environment,
} = app.update(Msg::Key(KeyPress::Reveal))
else {
panic!("`v` over an open gate reads the store");
};
Msg::Revealed {
key: row.key.clone(),
environment,
value: crate::lookout::secrets::stored_value(&store, &provider_cache, &row)
.map(RevealedValue),
}
}
#[track_caller]
pub fn app_revealing(home: &Path) -> App {
let mut app = app_with_secrets_and_reads(home, true);
let answer = ask_to_reveal(&mut app);
app.update(answer);
assert!(
matches!(app.body(), Body::Secrets(pane) if pane.reveal.is_some()),
"the fixture starts with a value on screen"
);
app
}
fn app_with_one_row_and_readers(readers: Vec<Reader>) -> App {
let mut app = full_app();
app.update(Msg::Key(KeyPress::Secrets));
app.update(Msg::Secrets {
environment: "production".to_string(),
result: Ok(Box::new(SecretsModel {
environments: vec!["production".to_string()],
rows: vec![SecretRow {
key: "DB_PASSWORD".to_string(),
source: Source::Operator,
in_force: Some("production".to_string()),
set_in: vec!["production".to_string()],
byte_len: Some(9),
readers,
}],
roll_age: Some(Duration::from_secs(184)),
..SecretsModel::default()
})),
});
app
}
pub fn render_secrets_with_readers() -> Buffer {
let app = app_with_one_row_and_readers(vec![
Reader {
name: "catcher".to_string(),
environment: "production".to_string(),
online: true,
},
Reader {
name: "web".to_string(),
environment: "production".to_string(),
online: false,
},
]);
render(&app, 160, 48)
}
pub fn render_secrets_with_more_readers_than_fit() -> Buffer {
let app = app_with_one_row_and_readers(
["catcher", "web", "worker", "api", "scheduler"]
.into_iter()
.map(|name| Reader {
name: name.to_string(),
environment: "production".to_string(),
online: true,
})
.collect(),
);
render(&app, 160, 48)
}
pub fn render_secrets_gate_shut() -> Buffer {
render(&app_with_secrets(), 160, 48)
}
pub fn render_secrets_with_roll_age(age: Duration) -> Buffer {
let mut app = full_app();
app.update(Msg::Key(KeyPress::Secrets));
app.update(Msg::Secrets {
environment: "production".to_string(),
result: Ok(Box::new(SecretsModel {
environments: vec!["production".to_string()],
rows: vec![SecretRow {
key: "DB_PASSWORD".to_string(),
source: Source::Operator,
in_force: Some("production".to_string()),
set_in: vec!["production".to_string()],
byte_len: Some(9),
readers: Vec::new(),
}],
roll_age: Some(age),
..SecretsModel::default()
})),
});
render(&app, 160, 48)
}
pub fn render_secrets_with_no_roll() -> Buffer {
render_secrets_gate_shut()
}
pub fn select_field(app: &mut App, key: &str) {
let pane = app.config_pane().expect("the pane is open");
let group = pane
.fields()
.by_key(key)
.unwrap_or_else(|| panic!("no field named {key}"))
.group
.clone();
if let Some(group) = group
&& let Some(position) = shep_core::config::GROUP_ORDER
.iter()
.position(|known| *known == group)
{
let digit = u8::try_from(position + 1).expect("eight groups fit a u8");
app.update(Msg::Key(KeyPress::Group(digit)));
}
let pane = app.config_pane().expect("the pane is open");
let index = pane
.rows()
.iter()
.position(|row| match row {
crate::lookout::pane::PaneRow::Field(field_index) => {
pane.fields().fields()[*field_index].key == key
}
crate::lookout::pane::PaneRow::Env(_) | crate::lookout::pane::PaneRow::AddEnv => false,
})
.unwrap_or_else(|| panic!("{key} is not in the active group's rows"));
app.update(Msg::Key(KeyPress::SelectFirst));
for _ in 0..index {
app.update(Msg::Key(KeyPress::SelectDown));
}
}
pub fn app_in_sheep_pane_with_two_edits() -> App {
let mut app = app_in_sheep_pane_with_nothing_parked();
for (key, typed) in [("cwd", "/srv/web"), ("max_memory", "40")] {
select_field(&mut app, key);
app.update(Msg::Key(KeyPress::Confirm));
for _ in 0..64 {
app.update(Msg::Key(KeyPress::TextBackspace));
}
for character in typed.chars() {
app.update(Msg::Key(KeyPress::TextChar(character)));
}
app.update(Msg::Key(KeyPress::TextApply));
}
assert_eq!(
app.config_pane().expect("the pane is open").edits().len(),
2,
"the fixture files two edits"
);
app
}
pub fn app_in_sheep_pane_with_one_edit() -> App {
let mut app = app_in_sheep_pane_with_nothing_parked();
file_edit(&mut app, "cwd", "/srv/web");
assert_eq!(
app.config_pane().expect("the pane is open").edits().len(),
1,
"the fixture files one edit"
);
app
}
pub fn invalid_config() -> RequestError {
RequestError::Rpc(RpcError {
code: RpcErrorCode::InvalidConfig,
message: "cwd: no such directory".to_string(),
daemon_version: None,
})
}
#[track_caller]
pub fn config_pane_field_rows_for_tests(app: &App) -> Vec<String> {
let pane = app.config_pane().expect("the pane is open");
pane.rows()
.into_iter()
.filter_map(|row| match row {
crate::lookout::pane::PaneRow::Field(index) => {
Some(pane.fields().fields()[index].key.clone())
}
crate::lookout::pane::PaneRow::Env(_) | crate::lookout::pane::PaneRow::AddEnv => None,
})
.collect()
}
#[track_caller]
pub fn config_pane_env_rows_for_tests(pane: &crate::lookout::pane::ConfigPane) -> Vec<String> {
let lines = crate::lookout::view::pane::pane_lines(pane, plain(), 160, 0);
let rendered_lines: Vec<String> = lines.iter().map(rendered).collect();
let header = rendered_lines
.iter()
.position(|line| {
line.strip_prefix(" env")
.is_some_and(|rest| rest.is_empty() || rest.starts_with(' '))
})
.expect("the pane draws an env section header");
let env_rows = &rendered_lines[header + 1..];
let mut rows: Vec<String> = pane
.env_key_names()
.iter()
.map(|name| {
env_rows
.iter()
.find(|line| {
line.trim_start_matches(['>', ' '])
.starts_with(name.as_str())
})
.unwrap_or_else(|| panic!("no row for env key {name}"))
.clone()
})
.collect();
rows.push(
env_rows
.iter()
.find(|line| line.contains("add a key"))
.expect("the pane draws a + add a key row")
.clone(),
);
rows
}
#[track_caller]
pub fn config_pane_pending_rows_for_tests(app: &App) -> Vec<String> {
let pane = app.config_pane().expect("the pane is open");
pane.edits()
.iter()
.map(|(key, _)| match key {
crate::lookout::edits::EditKey::Field(name) => name.clone(),
crate::lookout::edits::EditKey::Env(name) => format!("env.{name}"),
})
.collect()
}
#[track_caller]
pub fn config_pane_row_for_tests(app: &App, key: &str) -> String {
let pane = app.config_pane().expect("the pane is open");
let lines = crate::lookout::view::pane::pane_lines(pane, plain(), 160, 0);
lines
.iter()
.map(rendered)
.find(|line| {
line.trim_start_matches(['>', ' ', '=', '~', '!', '*'])
.starts_with(key)
})
.unwrap_or_else(|| panic!("no row for {key}"))
}
#[track_caller]
pub fn config_pane_title_band_for_tests(app: &App, width: u16) -> String {
let pane = app.config_pane().expect("the pane is open");
let lines = crate::lookout::view::pane::pane_lines(pane, plain(), width, 0);
rendered(&lines[0])
}
#[track_caller]
pub fn config_pane_tab_row_for_tests(app: &App, width: u16) -> String {
let pane = app.config_pane().expect("the pane is open");
let lines = crate::lookout::view::pane::pane_lines(pane, plain(), width, 0);
lines
.iter()
.map(rendered)
.find(|line| line.contains("tab next group"))
.expect("the pane draws a tab row at this width")
}
pub fn config_pane_draws_a_tab_row(app: &App, width: u16) -> bool {
let pane = app.config_pane().expect("the pane is open");
let lines = crate::lookout::view::pane::pane_lines(pane, plain(), width, 0);
lines
.iter()
.map(rendered)
.any(|line| line.contains("tab next group"))
}
pub fn config_pane_draws_a_panel(app: &App, width: u16) -> bool {
let pane = app.config_pane().expect("the pane is open");
let lines = crate::lookout::view::pane::pane_lines(pane, plain(), width, 0);
lines
.iter()
.map(rendered)
.any(|line| line.contains("FOCUSED"))
}
pub fn app_with_plain_palette_in_sheep_pane() -> App {
app_in_sheep_pane()
}
#[track_caller]
pub fn config_pane_panel_for_tests(app: &App, width: u16) -> Vec<String> {
let pane = app.config_pane().expect("the pane is open");
crate::lookout::view::pane::panel_lines(pane, app.palette(), width)
.iter()
.map(rendered)
.collect()
}
#[track_caller]
pub fn config_pane_panel_focused_on(app: &App, key: &str, width: u16) -> Vec<String> {
let pane = app.config_pane().expect("the pane is open");
let field = pane
.fields()
.by_key(key)
.unwrap_or_else(|| panic!("no field named {key}"));
crate::lookout::view::pane::panel_for_field(field, pane, app.palette(), width)
.iter()
.map(rendered)
.collect()
}
pub fn a_refusal() -> RequestError {
RequestError::Rpc(shep_core::protocol::RpcError {
code: shep_core::protocol::RpcErrorCode::InvalidConfig,
message: "the store is locked by another shep".to_owned(),
daemon_version: None,
})
}