use crate::app::window_manager::WindowManager;
use crate::app::window_state::WindowState;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use winit::event_loop::ActiveEventLoop;
use winit::keyboard::{
Key, KeyCode, ModifiersState, NamedKey, NativeKey, NativeKeyCode, PhysicalKey,
};
fn default_wait_ms() -> u64 {
250
}
#[derive(Debug, Clone)]
pub(crate) struct UiTestScript {
pub(crate) steps: Vec<UiTestStep>,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct UiTestStep {
#[serde(default = "default_wait_ms")]
pub(crate) wait_ms: u64,
#[serde(flatten)]
pub(crate) action: UiTestAction,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub(crate) enum UiTestAction {
Chord { chord: String },
TypeText { type_text: String },
Press { press: String },
Assert { assert: String },
AssertNot { assert_not: String },
AssertEq { assert_eq: (String, String) },
Capture { capture: String },
AssertEqCaptured { assert_eq_captured: String },
OpenModal { open_modal: String },
SeedClipboard { seed_clipboard: Vec<String> },
CloseModal { close_modal: String },
}
const SINK_ENV_VAR: &str = "PAR_TERM_UI_TEST_SINK";
fn resolve_sink_var(operand: &str) -> anyhow::Result<String> {
let bare = format!("${SINK_ENV_VAR}");
let braced = format!("${{{SINK_ENV_VAR}}}");
if !operand.contains(&bare) && !operand.contains(&braced) {
return Ok(operand.to_string());
}
match std::env::var(SINK_ENV_VAR) {
Ok(value) => Ok(operand.replace(&braced, &value).replace(&bare, &value)),
Err(_) => Err(anyhow::anyhow!(
"--ui-test: ${SINK_ENV_VAR} referenced but not set — export it to this run's sink path"
)),
}
}
fn split_file_bytes_operand(operand: &str) -> Option<(&str, &str, bool)> {
if let Some(star) = operand.rfind('*') {
return Some((&operand[..star], &operand[star + 1..], false));
}
let (path, hex) = operand.rsplit_once(':')?;
if hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
Some((path, hex, true))
}
fn sink_paths(steps: &[UiTestStep]) -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = steps
.iter()
.filter_map(|step| match &step.action {
UiTestAction::AssertEq {
assert_eq: (what, operand),
} => match (what.as_str(), operand.as_str()) {
("file_empty", path) => Some(path),
("file_bytes", path) => split_file_bytes_operand(path).map(|(p, _, _)| p),
_ => None,
},
_ => None,
})
.filter(|p| !p.is_empty())
.map(PathBuf::from)
.collect();
paths.sort();
paths.dedup();
paths
}
fn reset_sink_files(steps: &[UiTestStep]) -> anyhow::Result<()> {
for path in sink_paths(steps) {
match std::fs::remove_file(&path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return Err(anyhow::anyhow!(
"--ui-test: cannot reset sink {}: {e}",
path.display()
));
}
Ok(()) => {}
}
}
Ok(())
}
pub(crate) fn load_script(path: &Path) -> anyhow::Result<UiTestScript> {
let raw = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("--ui-test: cannot read {}: {}", path.display(), e))?;
#[derive(Deserialize)]
struct RawScript {
steps: Vec<UiTestStep>,
}
let raw: RawScript = serde_json::from_str(&raw).map_err(|e| {
anyhow::anyhow!(
"--ui-test: invalid script JSON in {}: {}",
path.display(),
e
)
})?;
let mut steps = raw.steps;
for step in &mut steps {
if let UiTestAction::AssertEq {
assert_eq: (what, operand),
} = &mut step.action
&& matches!(what.as_str(), "file_bytes" | "file_empty")
{
*operand = resolve_sink_var(operand)?;
}
}
reset_sink_files(&steps)?;
Ok(UiTestScript { steps })
}
#[derive(Debug, Default)]
pub(crate) struct UiTestRun {
pub(crate) report_path: Option<PathBuf>,
pub(crate) records: Vec<StepRecord>,
pub(crate) failed: usize,
pub(crate) passed: usize,
pub(crate) captured: std::collections::HashMap<String, String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct StepRecord {
index: usize,
wait_ms: u64,
action: String,
ok: Option<bool>,
detail: String,
observation: Observation,
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct Observation {
palette_open: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
agent_usage_panel_open: bool,
search_open: bool,
settings_window_open: bool,
modal_guard: bool,
egui_keyboard: bool,
fullscreen: bool,
top_action: Option<String>,
modals: Vec<String>,
}
fn visible_modal_names(ws: &WindowState) -> Vec<String> {
ws.overlay_stack().guard_names()
}
const UNIDENTIFIED_PREFIX: &str = "Unidentified+";
fn physical_code_for_char(c: char) -> Option<KeyCode> {
Some(match c.to_ascii_uppercase() {
'A' => KeyCode::KeyA,
'B' => KeyCode::KeyB,
'C' => KeyCode::KeyC,
'D' => KeyCode::KeyD,
'E' => KeyCode::KeyE,
'F' => KeyCode::KeyF,
'G' => KeyCode::KeyG,
'H' => KeyCode::KeyH,
'I' => KeyCode::KeyI,
'J' => KeyCode::KeyJ,
'K' => KeyCode::KeyK,
'L' => KeyCode::KeyL,
'M' => KeyCode::KeyM,
'N' => KeyCode::KeyN,
'O' => KeyCode::KeyO,
'P' => KeyCode::KeyP,
'Q' => KeyCode::KeyQ,
'R' => KeyCode::KeyR,
'S' => KeyCode::KeyS,
'T' => KeyCode::KeyT,
'U' => KeyCode::KeyU,
'V' => KeyCode::KeyV,
'W' => KeyCode::KeyW,
'X' => KeyCode::KeyX,
'Y' => KeyCode::KeyY,
'Z' => KeyCode::KeyZ,
'0' => KeyCode::Digit0,
'1' => KeyCode::Digit1,
'2' => KeyCode::Digit2,
'3' => KeyCode::Digit3,
'4' => KeyCode::Digit4,
'5' => KeyCode::Digit5,
'6' => KeyCode::Digit6,
'7' => KeyCode::Digit7,
'8' => KeyCode::Digit8,
'9' => KeyCode::Digit9,
_ => return None,
})
}
fn chord_to_fields(chord: &str) -> Result<(Key, PhysicalKey, winit::event::Modifiers), String> {
use par_term_keybindings::parser::{ParsedKey, parse_key_combo};
let (unidentified, spec) = match chord.get(..UNIDENTIFIED_PREFIX.len()) {
Some(head) if head.eq_ignore_ascii_case(UNIDENTIFIED_PREFIX) => {
(true, &chord[UNIDENTIFIED_PREFIX.len()..])
}
_ => (false, chord),
};
let combo = parse_key_combo(spec).map_err(|e| format!("cannot parse chord '{chord}': {e}"))?;
let m = &combo.modifiers;
let mut state = ModifiersState::empty();
if m.ctrl {
state |= ModifiersState::CONTROL;
}
if m.alt {
state |= ModifiersState::ALT;
}
if m.shift {
state |= ModifiersState::SHIFT;
}
if m.super_key || (m.cmd_or_ctrl && cfg!(target_os = "macos")) {
state |= ModifiersState::SUPER;
}
if m.cmd_or_ctrl && !cfg!(target_os = "macos") {
state |= ModifiersState::CONTROL;
}
let modifiers = winit::event::Modifiers::from(state);
match combo.key {
ParsedKey::Character(c) if unidentified => {
let code = physical_code_for_char(c).ok_or_else(|| {
format!("chord '{chord}': Unidentified+ needs a letter or digit key, got '{c}'")
})?;
Ok((
Key::Unidentified(NativeKey::Windows(c.to_ascii_uppercase() as u16)),
PhysicalKey::Code(code),
modifiers,
))
}
_ if unidentified => Err(format!(
"chord '{chord}': Unidentified+ applies to letter or digit chords only"
)),
ParsedKey::Character(c) => {
let upper = c.to_ascii_uppercase().to_string();
Ok((
Key::Character(upper.into()),
PhysicalKey::Unidentified(NativeKeyCode::Unidentified),
modifiers,
))
}
ParsedKey::Named(named) => Ok((Key::Named(named), named_physical_code(&named), modifiers)),
ParsedKey::Physical(code) => {
Ok((Key::Dead(None), PhysicalKey::Code(code), modifiers))
}
}
}
fn named_physical_code(key: &NamedKey) -> PhysicalKey {
match key {
NamedKey::F1 => PhysicalKey::Code(KeyCode::F1),
NamedKey::F2 => PhysicalKey::Code(KeyCode::F2),
NamedKey::F3 => PhysicalKey::Code(KeyCode::F3),
NamedKey::F4 => PhysicalKey::Code(KeyCode::F4),
NamedKey::F5 => PhysicalKey::Code(KeyCode::F5),
NamedKey::F6 => PhysicalKey::Code(KeyCode::F6),
NamedKey::F7 => PhysicalKey::Code(KeyCode::F7),
NamedKey::F8 => PhysicalKey::Code(KeyCode::F8),
NamedKey::F9 => PhysicalKey::Code(KeyCode::F9),
NamedKey::F10 => PhysicalKey::Code(KeyCode::F10),
NamedKey::F11 => PhysicalKey::Code(KeyCode::F11),
NamedKey::F12 => PhysicalKey::Code(KeyCode::F12),
NamedKey::Enter => PhysicalKey::Code(KeyCode::Enter),
NamedKey::Escape => PhysicalKey::Code(KeyCode::Escape),
NamedKey::Tab => PhysicalKey::Code(KeyCode::Tab),
NamedKey::Backspace => PhysicalKey::Code(KeyCode::Backspace),
NamedKey::Delete => PhysicalKey::Code(KeyCode::Delete),
NamedKey::ArrowUp => PhysicalKey::Code(KeyCode::ArrowUp),
NamedKey::ArrowDown => PhysicalKey::Code(KeyCode::ArrowDown),
NamedKey::ArrowLeft => PhysicalKey::Code(KeyCode::ArrowLeft),
NamedKey::ArrowRight => PhysicalKey::Code(KeyCode::ArrowRight),
NamedKey::Home => PhysicalKey::Code(KeyCode::Home),
NamedKey::End => PhysicalKey::Code(KeyCode::End),
NamedKey::PageUp => PhysicalKey::Code(KeyCode::PageUp),
NamedKey::PageDown => PhysicalKey::Code(KeyCode::PageDown),
_ => PhysicalKey::Unidentified(NativeKeyCode::Unidentified),
}
}
fn press_modifiers(press: &str) -> (&str, egui::Modifiers) {
let mut modifiers = egui::Modifiers::NONE;
let mut rest = press;
loop {
if let Some(r) = rest.strip_prefix("Shift+") {
modifiers.shift = true;
rest = r;
} else if let Some(r) = rest.strip_prefix("Cmd+") {
modifiers |= egui::Modifiers::COMMAND;
rest = r;
} else if let Some(r) = rest.strip_prefix("Ctrl+") {
modifiers.ctrl = true;
rest = r;
} else {
return (rest, modifiers);
}
}
}
fn press_to_egui_key(name: &str) -> Option<egui::Key> {
Some(match name.to_ascii_lowercase().as_str() {
"enter" => egui::Key::Enter,
"escape" => egui::Key::Escape,
"tab" => egui::Key::Tab,
"backspace" => egui::Key::Backspace,
"delete" => egui::Key::Delete,
"arrowup" | "up" => egui::Key::ArrowUp,
"arrowdown" | "down" => egui::Key::ArrowDown,
"arrowleft" | "left" => egui::Key::ArrowLeft,
"arrowright" | "right" => egui::Key::ArrowRight,
"home" => egui::Key::Home,
"end" => egui::Key::End,
"pageup" => egui::Key::PageUp,
"pagedown" => egui::Key::PageDown,
"f1" => egui::Key::F1,
"f2" => egui::Key::F2,
"f3" => egui::Key::F3,
"f4" => egui::Key::F4,
"f5" => egui::Key::F5,
"f6" => egui::Key::F6,
"f7" => egui::Key::F7,
"f8" => egui::Key::F8,
"f9" => egui::Key::F9,
"f10" => egui::Key::F10,
"f11" => egui::Key::F11,
"f12" => egui::Key::F12,
s if s.len() == 1 && s.as_bytes()[0].is_ascii_alphabetic() => egui::Key::from_name(s)?,
_ => return None,
})
}
fn step_verdict(outcome: StepOutcome) -> (String, Option<bool>, String) {
match outcome {
StepOutcome::Performed(desc) => (desc, None, String::new()),
StepOutcome::Asserted {
desc,
passed,
detail,
} => (desc, Some(passed), detail),
StepOutcome::Failed(desc) => (desc.clone(), Some(false), desc),
}
}
impl WindowManager {
pub(crate) fn run_ui_test_step(&mut self, step: &UiTestStep) {
let index = self.ui_test.records.len() + 1;
let (action_desc, ok, detail) = step_verdict(self.ui_test_step_inner(step));
let observation = self.ui_test_observe();
if ok == Some(false) {
self.ui_test.failed += 1;
log::warn!("UI TEST step {index} FAILED: {action_desc} — {detail}");
} else if ok == Some(true) {
self.ui_test.passed += 1;
}
self.ui_test.records.push(StepRecord {
index,
wait_ms: step.wait_ms,
action: action_desc,
ok,
detail,
observation,
});
}
fn ui_test_step_inner(&mut self, step: &UiTestStep) -> StepOutcome {
let window_ids: Vec<_> = self.windows.keys().copied().collect();
let terminal_id = window_ids
.into_iter()
.find(|id| !self.is_settings_window(*id));
match &step.action {
UiTestAction::Chord { chord } => {
let Some(id) = terminal_id else {
return StepOutcome::Failed("chord: no terminal window yet".into());
};
let ws = self.windows.get_mut(&id).expect("id from keys()");
Self::inject_chord(ws, chord)
}
UiTestAction::TypeText { type_text } => {
let Some(id) = terminal_id else {
return StepOutcome::Failed("type_text: no terminal window yet".into());
};
let ws = self.windows.get_mut(&id).expect("id from keys()");
ws.egui
.pending_events
.push(egui::Event::Text(type_text.clone()));
Self::render_ui_test_frame(ws);
StepOutcome::Performed(format!("type_text \"{type_text}\" (egui)"))
}
UiTestAction::Press { press } => {
let (key_name, modifiers) = press_modifiers(press);
let Some(egui_key) = press_to_egui_key(key_name) else {
return StepOutcome::Failed(format!(
"press: unknown key name '{press}' (see AGENT_UI_VERIFICATION.md)"
));
};
let Some(id) = terminal_id else {
return StepOutcome::Failed("press: no terminal window yet".into());
};
let ws = self.windows.get_mut(&id).expect("id from keys()");
ws.egui.pending_events.push(egui::Event::Key {
key: egui_key,
physical_key: None,
pressed: true,
repeat: false,
modifiers,
});
Self::render_ui_test_frame(ws);
StepOutcome::Performed(format!("press {press} (egui)"))
}
UiTestAction::SeedClipboard { seed_clipboard } => {
let Some(id) = terminal_id else {
return StepOutcome::Failed("seed_clipboard: no terminal window yet".into());
};
let ws = self.windows.get_mut(&id).expect("id from keys()");
let Some(tab) = ws.tab_manager.active_tab() else {
return StepOutcome::Failed("seed_clipboard: no active tab".into());
};
let terminal = tab.read_terminal_handle();
let Ok(term) = terminal.try_read() else {
return StepOutcome::Failed("seed_clipboard: terminal lock busy".into());
};
for content in seed_clipboard {
term.add_to_clipboard_history(
par_term_terminal::ClipboardSlot::Clipboard,
content.clone(),
None,
);
}
StepOutcome::Performed(format!("seed_clipboard {} entries", seed_clipboard.len()))
}
UiTestAction::Assert { assert } => {
let value = self.ui_test_bool(assert);
match value {
Some(v) => StepOutcome::Asserted {
desc: format!("assert {assert}"),
passed: v,
detail: format!("{assert} = {v}"),
},
None => StepOutcome::Asserted {
desc: format!("assert {assert}"),
passed: false,
detail: format!("unknown assert operand '{assert}'"),
},
}
}
UiTestAction::OpenModal { open_modal } => {
let Some(id) = terminal_id else {
return StepOutcome::Failed("open_modal: no terminal window yet".into());
};
let ws = self.windows.get_mut(&id).expect("id from keys()");
Self::seed_modal_state(ws, open_modal, true)
}
UiTestAction::CloseModal { close_modal } => {
let Some(id) = terminal_id else {
return StepOutcome::Failed("close_modal: no terminal window yet".into());
};
let ws = self.windows.get_mut(&id).expect("id from keys()");
Self::seed_modal_state(ws, close_modal, false)
}
UiTestAction::AssertNot { assert_not } => {
let value = self.ui_test_bool(assert_not);
match value {
Some(v) => StepOutcome::Asserted {
desc: format!("assert_not {assert_not}"),
passed: !v,
detail: format!("{assert_not} = {v}"),
},
None => StepOutcome::Asserted {
desc: format!("assert_not {assert_not}"),
passed: false,
detail: format!("unknown assert operand '{assert_not}'"),
},
}
}
UiTestAction::AssertEq { assert_eq } => {
let (what, expected) = assert_eq;
match self.ui_test_value(what, expected) {
Ok((actual, passed)) => StepOutcome::Asserted {
desc: format!("assert_eq {what}"),
passed,
detail: format!("{what} = {actual:?} (expected {expected:?})"),
},
Err(err) => StepOutcome::Asserted {
desc: format!("assert_eq {what}"),
passed: false,
detail: err,
},
}
}
UiTestAction::Capture { capture } => match self.ui_test_operand(capture) {
Ok(actual) => {
self.ui_test
.captured
.insert(capture.clone(), actual.clone());
StepOutcome::Performed(format!("capture {capture} = {actual}"))
}
Err(err) => StepOutcome::Failed(format!("capture {capture}: {err}")),
},
UiTestAction::AssertEqCaptured { assert_eq_captured } => {
let what = assert_eq_captured.as_str();
let Some(expected) = self.ui_test.captured.get(what).cloned() else {
return StepOutcome::Asserted {
desc: format!("assert_eq_captured {what}"),
passed: false,
detail: format!("no captured value for operand '{what}'"),
};
};
match self.ui_test_operand(what) {
Ok(actual) => StepOutcome::Asserted {
desc: format!("assert_eq_captured {what}"),
passed: actual == expected,
detail: format!("{what} = {actual:?} (captured {expected:?})"),
},
Err(err) => StepOutcome::Asserted {
desc: format!("assert_eq_captured {what}"),
passed: false,
detail: err,
},
}
}
}
}
fn seed_modal_state(ws: &mut WindowState, name: &str, open: bool) -> StepOutcome {
use crate::app::window_state::PendingTriggerAction;
use par_term_config::agent_commands::CommandAuthor;
let verb = if open { "open_modal" } else { "close_modal" };
match name {
"close_running_job" => {
if open {
let Some(tab_id) = ws.tab_manager.active_tab_id() else {
return StepOutcome::Failed(format!("{verb} {name}: no active tab"));
};
ws.overlay_ui.close_confirmation_ui.show_for_tab(
tab_id,
"ui-test tab",
"sleep 100",
);
} else {
ws.overlay_ui.close_confirmation_ui.hide();
}
}
"mux_last_tab" => {
if open {
ws.overlay_ui.mux_last_tab_ui.show_for_session("ui-test");
} else {
ws.overlay_ui.mux_last_tab_ui.hide();
}
}
"trigger_confirm" => {
if open {
ws.trigger_state
.pending_trigger_actions
.push(PendingTriggerAction {
trigger_id: 0,
trigger_name: "ui-test trigger".to_string(),
action: par_term_emu_core_rust::terminal::ActionResult::RunCommand {
trigger_id: 0,
command: "echo".to_string(),
args: vec!["b61".to_string()],
},
description: "ui-test pending trigger action".to_string(),
target: None,
});
} else {
ws.trigger_state.pending_trigger_actions.clear();
}
}
"agent_command_confirm" => {
if open {
let file = par_term_config::agent_commands::AgentCommandFile {
created_by: CommandAuthor::User,
source_agent: None,
created_at: None,
action: par_term_config::CustomActionConfig::ShellCommand {
id: "ui-test-b61".to_string(),
title: "ui-test b61".to_string(),
command: "echo".to_string(),
args: vec!["b61".to_string()],
notify_on_success: false,
timeout_secs: 30,
capture_output: false,
keybinding: None,
prefix_char: None,
keybinding_enabled: true,
description: None,
},
};
ws.agent_commands.request_confirmation(file);
} else {
ws.agent_commands.pending_confirmations.clear();
}
}
"update_dialog" => {
if open {
ws.update_state.show_dialog = true;
ws.update_state.last_result = Some(
par_term_update::update_checker::UpdateCheckResult::UpdateAvailable(
par_term_update::update_checker::UpdateInfo {
version: "999.0.0".to_string(),
release_notes: None,
release_url: "https://example.invalid".to_string(),
published_at: None,
},
),
);
} else {
ws.update_state.show_dialog = false;
}
}
"tab_context_menu" => {
if open {
let Some(tab_id) = ws.tab_manager.active_tab_id() else {
return StepOutcome::Failed(format!("{verb} {name}: no active tab"));
};
ws.tab_bar_ui.test_open_context_menu(tab_id);
} else {
ws.tab_bar_ui.test_close_context_menu();
}
}
"profile_launcher" => {
if open {
ws.open_profile_launcher();
} else {
ws.overlay_ui.profile_launcher_ui.close();
}
}
"demote_chooser" => {
if open {
let Some(tab_id) = ws.tab_manager.active_tab_id() else {
return StepOutcome::Failed(format!("{verb} {name}: no active tab"));
};
let pane_id = ws
.tab_manager
.active_tab()
.and_then(|t| t.focused_pane_id());
ws.pane_transfer_state = match pane_id {
Some(target_pane_id) => {
crate::app::tab_ops::pane_transfer::PaneTransferState::DemoteChooseDirection {
source_tab_id: tab_id,
target_tab_id: tab_id,
target_pane_id,
}
}
None => crate::app::tab_ops::pane_transfer::PaneTransferState::DemotePickTab {
source_tab_id: tab_id,
},
};
} else {
ws.cancel_pane_transfer();
}
}
"profile_drawer" => {
if ws.overlay_ui.profile_drawer_ui.expanded != open {
ws.toggle_profiles_drawer();
}
}
"quit_confirmation" => {
if open {
ws.overlay_ui
.quit_confirmation_ui
.show_confirmation(1, None);
} else {
ws.overlay_ui.quit_confirmation_ui.hide();
}
}
"tmux_picker" => {
if open {
ws.overlay_ui.tmux_session_picker_ui.show_picker();
} else {
ws.overlay_ui.tmux_session_picker_ui.hide();
}
}
"command_history" => {
if ws.overlay_ui.command_history_ui.visible != open {
ws.toggle_command_history();
}
}
"clipboard_history" => {
if ws.overlay_ui.clipboard_history_ui.visible != open {
ws.toggle_clipboard_history();
}
}
_ => {
return StepOutcome::Failed(format!(
"{verb}: unknown dialog '{name}' (see UiTestAction::OpenModal docs)"
));
}
}
ws.request_redraw();
StepOutcome::Performed(format!("{verb} {name}"))
}
fn render_ui_test_frame(ws: &mut WindowState) {
ws.focus_state.last_render_time = None;
ws.render();
ws.request_redraw();
}
fn inject_chord(ws: &mut WindowState, chord: &str) -> StepOutcome {
let fields = match chord_to_fields(chord) {
Ok(f) => f,
Err(err) => return StepOutcome::Failed(format!("chord: {err}")),
};
let (logical, physical, modifiers) = fields;
let facts = ws.overlay_key_facts(&logical, physical, &modifiers);
let route = ws.route_overlay_key(&facts);
if ws.apply_overlay_route(&route) {
return StepOutcome::Performed(format!("chord {chord} -> overlay stack: {route:?}"));
}
if !route.continues_to_key_dispatch() {
return StepOutcome::Performed(format!(
"chord {chord} -> blocked by modal guard (overlay open): {route:?}"
));
}
if ws.pane_hint_select.is_active() {
let typed = match &logical {
Key::Character(ch) => ch.chars().next(),
_ => None,
};
ws.resolve_pane_hint_select(typed);
return StepOutcome::Performed(format!("chord {chord} -> resolved pane-hint mode"));
}
if ws.pane_resize_mode.is_active() {
let key = crate::app::pane_resize_mode::ResizeModeKey::from_key(
&logical,
modifiers.state().shift_key(),
);
let cell = ws.resize_mode_cell_size();
ws.resolve_pane_resize_key(key, cell);
return StepOutcome::Performed(format!("chord {chord} -> resize mode"));
}
if ws.status_bar_ui.plugin_host().focused_overlay().is_some() {
let is_escape = matches!(&logical, Key::Named(NamedKey::Escape));
ws.resolve_focused_overlay_key(is_escape);
return StepOutcome::Performed(format!(
"chord {chord} -> swallowed by focused overlay{}",
if is_escape { " (unfocused)" } else { "" }
));
}
ws.input_handler.update_modifiers(modifiers);
let press = crate::app::leader::LeaderPress {
logical: &logical,
physical,
pressed: true,
os_repeat: false,
};
if let Some(step) = ws.handle_leader_press(press) {
ws.request_redraw();
return StepOutcome::Performed(format!("chord {chord} -> leader {step:?}"));
}
let config = ws.config.load();
let action = ws.keybinding_registry.lookup_with_key_fields(
&logical,
physical,
&modifiers,
&config.input.modifier_remapping,
config.input.use_physical_keys,
);
drop(config);
match action {
Some(action) => {
let action = action.to_string();
if action == par_term_keybindings::PASS_TO_TERMINAL {
Self::inject_chord_to_pty(ws, chord, "pass_to_terminal", logical, physical)
} else {
let handled = ws.execute_keybinding_action(&action);
ws.request_redraw();
StepOutcome::Performed(format!(
"chord {chord} -> keybinding '{action}' (handled={handled})"
))
}
}
None => Self::inject_chord_to_pty(ws, chord, "unbound fallthrough", logical, physical),
}
}
fn inject_chord_to_pty(
ws: &mut WindowState,
chord: &str,
via: &str,
logical: Key,
physical: PhysicalKey,
) -> StepOutcome {
if ws.any_modal_ui_visible() {
return StepOutcome::Performed(format!(
"chord {chord} -> {via} -> consumed by modal guard tail (no PTY write)"
));
}
let (modify_other_keys_mode, application_cursor) = {
let Some(tab) = ws.tab_manager.active_tab() else {
return StepOutcome::Failed(format!("chord {chord} -> PTY: no active tab"));
};
if let Some(ref pane_manager) = tab.pane_manager
&& let Some(focused_pane) = pane_manager.focused_pane()
&& let Ok(term) = focused_pane.terminal.try_read()
{
(term.modify_other_keys_mode(), term.application_cursor())
} else {
let (m, a, _) = tab.read_or_cached_modes();
(m, a)
}
};
let input = par_term_input::KeyInput {
logical_key: logical,
physical_key: physical,
state: winit::event::ElementState::Pressed,
};
let Some(bytes) = ws.input_handler.handle_key_input_with_mode(
&input,
modify_other_keys_mode,
application_cursor,
) else {
return StepOutcome::Failed(format!("chord {chord} -> PTY: encoder declined the key"));
};
let terminal = {
let Some(tab) = ws.tab_manager.active_tab() else {
return StepOutcome::Failed(format!("chord {chord} -> PTY: no active tab"));
};
if let Some(ref pane_manager) = tab.pane_manager
&& let Some(focused_pane) = pane_manager.focused_pane()
{
std::sync::Arc::clone(&focused_pane.terminal)
} else {
std::sync::Arc::clone(&tab.terminal)
}
};
let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
let byte_count = bytes.len();
let mut write_result: Result<(), String> = Ok(());
let mut contended = false;
{
match terminal.try_read() {
Ok(term) => write_result = term.write(&bytes).map_err(|e| e.to_string()),
Err(_) => contended = true,
}
}
if contended {
ws.runtime.spawn(async move {
let term = terminal.read().await;
if let Err(e) = term.write(&bytes) {
crate::debug_error!("INPUT", "PTY write failed (ui-test passthrough): {e}");
}
});
}
match write_result {
Ok(()) => StepOutcome::Performed(format!(
"chord {chord} -> {via} -> PTY ({byte_count} bytes: {hex})"
)),
Err(e) => StepOutcome::Failed(format!("chord {chord} -> PTY write failed: {e}")),
}
}
fn ui_test_bool(&self, name: &str) -> Option<bool> {
Some(match name {
"palette_open" => self.ui_test_palette_open(),
"agent_usage_panel_open" => self.ui_test_agent_usage_panel_open(),
"settings_window_open" => self.settings_window.is_some(),
_ => {
let ws = self.ui_test_window_state()?;
match name {
"search_open" => ws.overlay_ui.search_ui.visible,
"agent_usage_ready" => !ws.status_bar_ui.usage_snapshot().records.is_empty(),
"plugins_loaded" => ws.status_bar_ui.plugins_discovered_count() >= 1,
"plugin_widget_set" => ws.status_bar_ui.any_plugin_widget_text(),
"plugin_panel_set" => ws.status_bar_ui.any_plugin_panel_pushed(),
"plugin_overlay_set" => !ws.status_bar_ui.plugin_host().overlays().is_empty(),
"plugin_overlay_interactive" => ws
.status_bar_ui
.plugin_host()
.overlays()
.values()
.any(|o| o.interactive),
"plugin_overlay_focused" => {
ws.status_bar_ui.plugin_host().focused_overlay().is_some()
}
"plugin_overlay_event" => {
ws.status_bar_ui
.plugin_host()
.overlay_events_dispatched_count()
> 0
}
"plugin_action_dispatched" => ws.status_bar_ui.any_plugin_action_dispatched(),
"modal_guard" => ws.any_modal_ui_visible(),
"tmux_picker_open" => ws.overlay_ui.tmux_session_picker_ui.visible,
"command_history_open" => ws.overlay_ui.command_history_ui.visible,
"clipboard_history_open" => ws.overlay_ui.clipboard_history_ui.visible,
"paste_special_open" => ws.overlay_ui.paste_special_ui.visible,
"palette_selected_visible" => {
ws.overlay_ui.command_palette.selected_row_is_visible()
}
"pane_hint_mode_active" => ws.pane_hint_select.is_active(),
"leader_armed" => ws.leader.is_armed(),
"which_key_open" => ws.overlay_state.which_key.is_some(),
"pane_zoomed" => ws
.tab_manager
.active_tab()
.and_then(|t| t.pane_manager())
.is_some_and(|pm| pm.is_zoomed()),
"egui_keyboard" => ws.is_egui_using_keyboard(),
"fullscreen" => ws.window.as_ref().is_some_and(|w| w.fullscreen().is_some()),
other => {
let name = other.strip_prefix("overlay_open:")?;
let id = crate::app::overlay::OverlayId::ALL
.iter()
.find(|id| id.name() == name)?;
ws.overlay_is_open(*id)
}
}
}
})
}
fn ui_test_operand(&self, what: &str) -> Result<String, String> {
match what {
"tab_shell_pid" => {
let Some(ws) = self.ui_test_window_state() else {
return Err("tab_shell_pid: no terminal window".into());
};
let Some(tab) = ws.tab_manager.active_tab() else {
return Err("tab_shell_pid: no active tab".into());
};
match tab.try_with_read_terminal(|t| t.get_shell_pid()) {
Some(Some(pid)) => Ok(pid.to_string()),
Some(None) => {
Err("tab_shell_pid: PTY child pid unavailable (session exited?)".into())
}
None => Err("tab_shell_pid: terminal lock contention".into()),
}
}
_ => Err(format!(
"operand '{what}' has no capturable value (see AGENT_UI_VERIFICATION.md)"
)),
}
}
fn ui_test_value(&self, what: &str, expected: &str) -> Result<(String, bool), String> {
match what {
"top_action" => {
let Some(ws) = self.ui_test_window_state() else {
return Err("top_action: no terminal window".into());
};
let actual = ws
.overlay_ui
.command_palette
.top_action()
.unwrap_or("<none>")
.to_string();
Ok((actual.clone(), actual == expected))
}
"palette_selected" => {
let Some(ws) = self.ui_test_window_state() else {
return Err("palette_selected: no terminal window".into());
};
let actual = ws.overlay_ui.command_palette.selected_index().to_string();
Ok((actual.clone(), actual == expected))
}
"window_count" => {
let actual = self.windows.len().to_string();
Ok((actual.clone(), actual == expected))
}
"tab_count" | "active_tab" | "pane_count" => {
let Some(ws) = self.ui_test_window_state() else {
return Err(format!("{what}: no terminal window"));
};
let tabs = &ws.tab_manager;
let actual = match what {
"tab_count" => tabs.visible_tab_count().to_string(),
"active_tab" => tabs
.active_tab_index()
.map_or("<none>".to_string(), |i| (i + 1).to_string()),
_ => tabs.active_tab().map_or(0, |t| t.pane_count()).to_string(),
};
Ok((actual.clone(), actual == expected))
}
"command_history_selected" => {
let Some(ws) = self.ui_test_window_state() else {
return Err("command_history_selected: no terminal window".into());
};
let actual = ws
.overlay_ui
.command_history_ui
.selected_command()
.unwrap_or("<none>".into());
Ok((actual.clone(), actual == expected))
}
"clipboard_history_selected" => {
let Some(ws) = self.ui_test_window_state() else {
return Err("clipboard_history_selected: no terminal window".into());
};
let actual = ws
.overlay_ui
.clipboard_history_ui
.selected_entry()
.map(|e| e.content.clone())
.unwrap_or("<none>".into());
Ok((actual.clone(), actual == expected))
}
"font_size" => {
let Some(ws) = self.ui_test_window_state() else {
return Err("font_size: no terminal window".into());
};
let actual = ws.config.load().font_size.to_string();
Ok((actual.clone(), actual == expected))
}
"file_empty" => {
let meta = std::fs::metadata(expected);
let actual = match &meta {
Ok(m) => format!("{} bytes", m.len()),
Err(_) => "missing".to_string(),
};
let empty = matches!(&meta, Ok(m) if m.len() == 0)
|| matches!(&meta, Err(e) if e.kind() == std::io::ErrorKind::NotFound);
Ok((actual, empty))
}
"file_bytes" => {
let (path, expected_hex, exact) = match split_file_bytes_operand(expected) {
Some(split) => split,
None => {
return Err("file_bytes: expected '<path>:<hex>' or '<path>*<hex>'".into());
}
};
let expected_hex = expected_hex.to_ascii_lowercase();
let actual = std::fs::read(path)
.map(|bytes| bytes.iter().map(|b| format!("{b:02x}")).collect::<String>())
.unwrap_or_else(|_| "missing".to_string());
let ok = actual == expected_hex || (!exact && actual.ends_with(&expected_hex));
Ok((actual, ok))
}
"mux_pane_grid" => {
let Some(ws) = self.ui_test_window_state() else {
return Err("mux_pane_grid: no terminal window".into());
};
let owners = &ws.tmux_state.tmux_pane_owners;
if owners.is_empty() {
return Err("mux_pane_grid: no par-mux panes attached".into());
}
let mut entries: Vec<(u64, String)> = Vec::new();
for (mux_id, (tab_id, pane_id)) in owners {
let Some(tab) = ws.tab_manager.get_tab(*tab_id) else {
return Err(format!(
"mux_pane_grid: tab {tab_id:?} holding %{mux_id} not found"
));
};
let Some(pm) = tab.pane_manager() else {
return Err(format!(
"mux_pane_grid: tab {tab_id:?} holding %{mux_id} has no panes"
));
};
let Some(pane) = pm.get_pane(*pane_id) else {
return Err(format!(
"mux_pane_grid: pane {pane_id:?} for %{mux_id} not found"
));
};
let (cols, rows) = match pane.terminal.try_read() {
Ok(term) => term.dimensions(),
Err(e) => {
return Err(format!("mux_pane_grid: %{mux_id} terminal lock: {e}"));
}
};
entries.push((*mux_id, format!("{cols}x{rows}")));
}
entries.sort_by_key(|(id, _)| *id);
let actual = if entries.len() == 1 {
entries
.into_iter()
.next()
.map(|(_, g)| g)
.unwrap_or_default()
} else {
entries
.into_iter()
.map(|(id, g)| format!("%{id}={g}"))
.collect::<Vec<_>>()
.join(",")
};
Ok((actual.clone(), actual == expected))
}
_ => Err(format!("unknown assert_eq operand '{what}'")),
}
}
fn ui_test_palette_open(&self) -> bool {
self.ui_test_window_state()
.is_some_and(|ws| ws.overlay_ui.command_palette.visible)
}
fn ui_test_agent_usage_panel_open(&self) -> bool {
self.ui_test_window_state()
.is_some_and(|ws| ws.overlay_ui.agent_usage_panel.visible)
}
fn ui_test_window_state(&self) -> Option<&WindowState> {
self.windows
.iter()
.find(|(id, _)| !self.is_settings_window(**id))
.map(|(_, ws)| ws)
}
fn ui_test_observe(&self) -> Observation {
let ws = self.ui_test_window_state();
Observation {
palette_open: ws.is_some_and(|w| w.overlay_ui.command_palette.visible),
agent_usage_panel_open: ws.is_some_and(|w| w.overlay_ui.agent_usage_panel.visible),
search_open: ws.is_some_and(|w| w.overlay_ui.search_ui.visible),
settings_window_open: self.settings_window.is_some(),
modal_guard: ws.is_some_and(|w| w.any_modal_ui_visible()),
egui_keyboard: ws.is_some_and(|w| w.is_egui_using_keyboard()),
fullscreen: ws.is_some_and(|w| {
w.window
.as_ref()
.is_some_and(|win| win.fullscreen().is_some())
}),
top_action: ws
.and_then(|w| w.overlay_ui.command_palette.top_action())
.map(str::to_string),
modals: ws.map(visible_modal_names).unwrap_or_default(),
}
}
pub(crate) fn ui_test_finish(&mut self, event_loop: &ActiveEventLoop) {
let all_passed = self.ui_test.failed == 0;
#[derive(Serialize)]
struct Report<'a> {
passed: usize,
failed: usize,
all_passed: bool,
steps: &'a [StepRecord],
}
let report = Report {
passed: self.ui_test.passed,
failed: self.ui_test.failed,
all_passed,
steps: &self.ui_test.records,
};
match &self.ui_test.report_path {
Some(path) => match serde_json::to_string_pretty(&report) {
Ok(json) => {
if let Err(e) = std::fs::write(path, json) {
log::error!("UI TEST: cannot write report {}: {e}", path.display());
}
}
Err(e) => log::error!("UI TEST: cannot serialize report: {e}"),
},
None => log::warn!("UI TEST: no report path configured"),
}
log::info!(
"UI TEST finished: {} passed, {} failed — {}",
self.ui_test.passed,
self.ui_test.failed,
if all_passed {
"ALL PASSED"
} else {
"FAILURES PRESENT"
}
);
event_loop.exit();
}
}
enum StepOutcome {
Performed(String),
Asserted {
desc: String,
passed: bool,
detail: String,
},
Failed(String),
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[test]
fn chord_to_fields_maps_modifiers() {
let (logical, physical, modifiers) = chord_to_fields("Ctrl+Alt+Cmd+P").expect("parses");
assert!(matches!(logical, Key::Character(ref c) if c.as_str() == "P"));
assert!(matches!(physical, PhysicalKey::Unidentified(_)));
let state = modifiers.state();
assert!(state.control_key() && state.alt_key() && state.super_key());
assert!(!state.shift_key());
}
#[test]
fn unidentified_chord_reaches_the_matcher_by_physical_key() {
use par_term_config::ModifierRemapping;
use par_term_keybindings::KeybindingRegistry;
let (logical, physical, modifiers) =
chord_to_fields("Unidentified+Ctrl+Alt+S").expect("parses");
assert!(matches!(logical, Key::Unidentified(_)));
assert_eq!(physical, PhysicalKey::Code(KeyCode::KeyS));
let state = modifiers.state();
assert!(state.control_key() && state.alt_key() && !state.shift_key());
let registry = KeybindingRegistry::from_config(&[par_term_config::KeyBinding {
key: "Ctrl+Alt+S".into(),
action: "probe".into(),
}]);
let lookup = |l: &Key, p: PhysicalKey, m: &winit::event::Modifiers| {
registry
.lookup_with_key_fields(l, p, m, &ModifierRemapping::default(), false)
.map(str::to_string)
};
assert_eq!(
lookup(&logical, physical, &modifiers).as_deref(),
Some("probe")
);
let (plain_logical, plain_physical, plain_mods) =
chord_to_fields("Ctrl+Alt+S").expect("parses");
assert!(matches!(plain_logical, Key::Character(_)));
assert!(matches!(plain_physical, PhysicalKey::Unidentified(_)));
assert_eq!(
lookup(&plain_logical, plain_physical, &plain_mods).as_deref(),
Some("probe")
);
let (l, p, m) = chord_to_fields("Unidentified+Ctrl+Alt+T").expect("parses");
assert_eq!(lookup(&l, p, &m), None);
}
#[test]
fn unidentified_chord_rejects_non_character_keys() {
assert!(chord_to_fields("Unidentified+Ctrl+Alt+F5").is_err());
assert!(chord_to_fields("Unidentified+Ctrl+Alt+Comma").is_err());
}
#[test]
fn chord_to_fields_rejects_modifier_only_chord() {
assert!(chord_to_fields("Ctrl").is_err());
}
#[test]
fn named_keys_map_to_egui() {
assert_eq!(press_to_egui_key("Enter"), Some(egui::Key::Enter));
assert_eq!(press_to_egui_key("escape"), Some(egui::Key::Escape));
assert_eq!(press_to_egui_key("Nope"), None);
}
#[test]
fn single_letters_map_to_egui() {
assert_eq!(press_to_egui_key("r"), Some(egui::Key::R));
assert_eq!(press_to_egui_key("H"), Some(egui::Key::H));
assert_eq!(press_to_egui_key("rr"), None);
assert_eq!(press_to_egui_key("1"), None);
}
#[test]
fn failed_steps_carry_a_verdict() {
let (_, ok, _) = step_verdict(StepOutcome::Failed("press: unknown key name 'x'".into()));
assert_eq!(ok, Some(false));
}
#[test]
fn performed_steps_stay_verdict_neutral() {
let (_, ok, _) = step_verdict(StepOutcome::Performed(
"chord Ctrl+P -> blocked by modal guard (overlay open)".into(),
));
assert_eq!(ok, None);
}
#[test]
fn ui_test_action_parses_capture_steps() {
let step: UiTestStep =
serde_json::from_str(r#"{"capture": "tab_shell_pid"}"#).expect("parses");
assert!(matches!(
step.action,
UiTestAction::Capture { ref capture } if capture == "tab_shell_pid"
));
let step: UiTestStep =
serde_json::from_str(r#"{"assert_eq_captured": "tab_shell_pid"}"#).expect("parses");
assert!(matches!(
step.action,
UiTestAction::AssertEqCaptured {
ref assert_eq_captured
} if assert_eq_captured == "tab_shell_pid"
));
}
static SINK_ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn sink_placeholder_resolves_from_the_environment() {
let _guard = SINK_ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().expect("tempdir");
let sink = dir.path().join("sink.bin");
unsafe { std::env::set_var(SINK_ENV_VAR, &sink) };
for form in [
format!(r#"${{{SINK_ENV_VAR}}}:0a"#),
format!(r#"${SINK_ENV_VAR}:0a"#),
] {
let resolved = resolve_sink_var(&form).expect("resolves");
assert_eq!(resolved, format!("{}:0a", sink.display()));
}
assert_eq!(
resolve_sink_var("/tmp/literal.bin:0a").expect("literal unchanged"),
"/tmp/literal.bin:0a"
);
unsafe { std::env::remove_var(SINK_ENV_VAR) };
}
#[test]
fn file_bytes_operand_splits_from_the_end() {
assert_eq!(
split_file_bytes_operand(r"C:\tmp\sink.bin:0a"),
Some((r"C:\tmp\sink.bin", "0a", true))
);
assert_eq!(
split_file_bytes_operand("/tmp/sink.bin:0a"),
Some(("/tmp/sink.bin", "0a", true))
);
assert_eq!(
split_file_bytes_operand(r"C:\tmp\sink*5aa1"),
Some((r"C:\tmp\sink", "5aa1", false))
);
assert_eq!(
split_file_bytes_operand("/tmp/capture"),
None,
"no suffix: the file_bytes caller must reject it"
);
}
#[test]
fn unset_sink_placeholder_fails_the_load() {
let _guard = SINK_ENV_LOCK.lock().unwrap();
unsafe { std::env::remove_var(SINK_ENV_VAR) };
let dir = tempfile::tempdir().expect("tempdir");
let script_path = dir.path().join("script.json");
std::fs::write(
&script_path,
format!(r#"{{"steps": [{{"assert_eq": ["file_bytes", "${{{SINK_ENV_VAR}}}:0a"]}}]}}"#),
)
.expect("write script");
let err = load_script(&script_path).expect_err("unset var must fail the load");
assert!(err.to_string().contains(SINK_ENV_VAR));
}
#[test]
fn load_script_resets_stale_sink_files() {
let dir = tempfile::tempdir().expect("tempdir");
let bytes_sink = dir.path().join("sink.bin");
std::fs::write(&bytes_sink, [0x0a]).expect("plant stale sink");
let script_path = dir.path().join("bytes.json");
std::fs::write(
&script_path,
format!(
r#"{{"steps": [{{"assert_eq": ["file_bytes", "{}:0a"]}}]}}"#,
bytes_sink.display().to_string().replace('\\', "\\\\")
),
)
.expect("write script");
load_script(&script_path).expect("loads");
assert!(!bytes_sink.exists(), "stale delivery sink must be reset");
let empty_sink = dir.path().join("capture.txt");
std::fs::write(&empty_sink, b"leaked").expect("plant stale capture");
let script_path = dir.path().join("empty.json");
std::fs::write(
&script_path,
format!(
r#"{{"steps": [{{"assert_eq": ["file_empty", "{}"]}}]}}"#,
empty_sink.display().to_string().replace('\\', "\\\\")
),
)
.expect("write script");
load_script(&script_path).expect("loads");
assert!(!empty_sink.exists(), "stale file_empty sink must be reset");
}
}