use super::view::{App, Dialog, Focus, Screen};
use super::{handle, take_stock};
use crate::workspace::Workspace;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
static SIGNALS: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn exclusive() -> std::sync::MutexGuard<'static, ()> {
let guard = SIGNALS.lock().unwrap_or_else(|e| e.into_inner());
ostraka_adapter::interrupt::clear();
guard
}
struct Scratch(PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).ok();
}
}
impl Scratch {
fn new(name: &str) -> Self {
let path = std::env::temp_dir().join(format!("ostraka-flow-{}-{name}", std::process::id()));
std::fs::remove_dir_all(&path).ok();
std::fs::create_dir_all(&path).expect("scratch dir");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
fn git(repo: &Path, args: &[&str]) {
let out = Command::new("git")
.args(args)
.current_dir(repo)
.output()
.expect("git runs");
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn project(name: &str, pause: u32) -> Scratch {
let scratch = Scratch::new(name);
let dir = scratch.path();
std::fs::create_dir_all(dir.join(".ostraka/adapters")).expect("ostraka");
std::fs::create_dir_all(dir.join("notes")).expect("notes");
let repo = dir.join("repositories/work");
std::fs::create_dir_all(&repo).expect("repository");
let body = format!(
"#!/bin/sh\n\
case \"$1\" in --probe) echo ok; exit 0;; esac\n\
marker=$(printf '%s' \"$1\" | grep -o 'VERDICT-[0-9a-f]*:' | head -1)\n\
if [ -n \"$marker\" ]; then\n\
\x20 echo \"the change does what the task asked\"\n\
\x20 echo \"$marker APPROVE\"\n\
else\n\
\x20 echo 'reading the repository'\n\
\x20 sleep {pause}\n\
\x20 printf 'written by the agent\\n' >> wrote.txt\n\
\x20 echo 'done'\n\
fi\n"
);
for id in ["writer", "reader"] {
let script = dir.join(format!("{id}.sh"));
std::fs::write(&script, &body).expect("script");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755))
.expect("chmod");
}
std::fs::write(
dir.join(format!(".ostraka/adapters/{id}.toml")),
format!(
"id = \"{id}\"\ncommand = \"{}\"\nargs = [\"{{{{prompt}}}}\"]\nprobe_args = [\"--probe\"]\n",
script.display()
),
)
.expect("profile");
}
std::fs::write(
dir.join(".ostraka/ostraka.toml"),
"[gate]\n\
checks = [{ name = \"check\", cmd = \"true\", required = true }]\n\n\
[gate.review]\n\
must_differ_from_author = true\n",
)
.expect("config");
std::fs::write(repo.join(".gitignore"), "/.ostraka/\n").expect("gitignore");
std::fs::write(repo.join("seed.txt"), "seed\n").expect("seed");
git(&repo, &["init", "-q", "-b", "main"]);
git(&repo, &["config", "user.email", "flow@example.invalid"]);
git(&repo, &["config", "user.name", "flow"]);
git(&repo, &["add", "-A"]);
git(&repo, &["commit", "-q", "-m", "seed"]);
scratch
}
fn repo_of(scratch: &Scratch) -> PathBuf {
scratch.path().join("repositories/work")
}
struct Driver {
app: App,
records_root: PathBuf,
width: u16,
height: u16,
}
impl Driver {
fn open(root: &Path) -> Self {
let workspace = Workspace::at(root);
let records_root = workspace.records();
let app = super::open(&workspace, &records_root).expect("the browser opens");
Self {
app,
records_root,
width: 110,
height: 26,
}
}
fn key(&mut self, code: KeyCode) -> &mut Self {
handle(
&mut self.app,
KeyEvent::new(code, KeyModifiers::NONE),
&self.records_root,
);
self
}
fn ctrl(&mut self, c: char) -> &mut Self {
handle(
&mut self.app,
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL),
&self.records_root,
);
self
}
fn typed(&mut self, text: &str) -> &mut Self {
for c in text.chars() {
self.key(KeyCode::Char(c));
}
self
}
fn tick(&mut self) -> &mut Self {
self.app.tick = self.app.tick.wrapping_add(1);
take_stock(&mut self.app, &self.records_root);
self
}
fn until(&mut self, what: &str, done: impl Fn(&App) -> bool) -> &mut Self {
let deadline = Instant::now() + Duration::from_secs(60);
while !done(&self.app) {
assert!(
Instant::now() < deadline,
"waited a minute for {what}, and the screen said:\n{}",
self.screen()
);
self.tick();
std::thread::sleep(Duration::from_millis(10));
}
self
}
fn task(&mut self, text: &str) -> &mut Self {
let done = self.app.thread().turns.len() + 1;
self.typed(text).key(KeyCode::Enter);
self.until("the run to finish", move |app| {
app.thread().turns.len() == done
})
}
fn last(&self) -> &super::thread::Turn {
self.app.thread().turns.last().expect("a finished turn")
}
fn screen(&mut self) -> String {
let mut terminal =
Terminal::new(TestBackend::new(self.width, self.height)).expect("test terminal");
let app = &mut self.app;
terminal
.draw(|frame| super::view::draw(frame, app))
.expect("draws");
let buffer = terminal.backend().buffer().clone();
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
fn shows(&mut self, text: &str) {
let screen = self.screen();
assert!(screen.contains(text), "expected {text:?} on:\n{screen}");
}
fn hides(&mut self, text: &str) {
let screen = self.screen();
assert!(
!screen.contains(text),
"did not expect {text:?} on:\n{screen}"
);
}
}
#[test]
fn a_browser_with_no_terminal_says_so_rather_than_panicking() {
let scratch = Scratch::new("no-tty");
let error = super::run(&Workspace::at(scratch.path())).expect_err("a pipe is not a terminal");
let said = error.to_string();
assert!(said.contains("needs a terminal"), "{said}");
assert!(
said.contains("ostraka runs"),
"no way out was offered: {said}"
);
}
#[test]
fn setting_up_a_directory_leaves_a_browser_that_can_run_something() {
let scratch = Scratch::new("setup");
std::fs::create_dir_all(scratch.path().join("repositories/work")).expect("repository");
std::fs::write(
scratch.path().join("repositories/work/Cargo.toml"),
"[package]\n",
)
.expect("write");
let mut d = Driver::open(scratch.path());
d.shows("not an Ostraka project yet");
d.shows("a Rust project");
d.shows("i set this directory up");
d.typed("a task this directory cannot take")
.key(KeyCode::Enter);
d.shows("cannot run anything yet");
assert!(d.app.thread().live.is_none());
d.ctrl('x').key(KeyCode::Char('i'));
d.hides("not an Ostraka project yet");
d.shows("Write a task below");
assert!(
scratch
.path()
.join(".ostraka/adapters/codex.toml")
.is_file()
);
}
#[test]
fn onboarding_an_empty_workspace_says_what_is_missing_before_it_is_missed() {
let scratch = Scratch::new("no-git");
let mut d = Driver::open(scratch.path());
d.shows("not an Ostraka project yet");
d.shows("Nothing has been cloned into");
d.shows("x walks through it");
d.shows("fails on purpose");
d.ctrl('x').key(KeyCode::Char('i'));
assert!(scratch.path().join(".ostraka/ostraka.toml").is_file());
assert!(
d.app.blocked.is_some(),
"the warning went away without the cause"
);
}
#[test]
fn a_task_in_a_repository_git_does_not_know_offers_the_steps_out_of_it() {
let scratch = Scratch::new("guided");
std::fs::create_dir_all(scratch.path().join("repositories/work")).expect("repository");
let mut d = Driver::open(scratch.path());
d.ctrl('x').key(KeyCode::Char('i'));
d.typed("write a file").key(KeyCode::Enter);
assert!(
d.app.thread().live.is_none(),
"a run was started with nowhere to work"
);
assert_eq!(d.app.dialog, Some(Dialog::Fix));
assert_eq!(
d.app.pane().prompt,
"write a file",
"the task was thrown away"
);
d.shows("not a git repository");
d.shows("git init");
d.key(KeyCode::Char('y'));
assert!(
scratch.path().join("repositories/work/.git").is_dir(),
"step one did nothing"
);
d.shows("commits everything");
for (key, value) in [
("user.email", "flow@example.invalid"),
("user.name", "flow"),
] {
assert!(
Command::new("git")
.args(["config", key, value])
.current_dir(scratch.path().join("repositories/work"))
.status()
.expect("git runs")
.success()
);
}
d.key(KeyCode::Char('y'));
assert!(
d.app.remedy.as_ref().is_some_and(|r| r.done()),
"the steps did not finish"
);
d.key(KeyCode::Esc);
d.ctrl('x').key(KeyCode::Char('x'));
d.shows("nothing is in the way");
assert!(d.app.blocked.is_none());
}
#[test]
fn a_workspace_with_nothing_in_it_can_start_a_repository_and_work_in_it() {
let scratch = Scratch::new("start-here");
let mut d = Driver::open(scratch.path());
d.ctrl('x').key(KeyCode::Char('i'));
d.shows("Nothing has been cloned into");
d.ctrl('x').key(KeyCode::Char('w'));
assert_eq!(d.app.dialog, Some(Dialog::Repos));
d.shows("start one here");
d.key(KeyCode::Char('n')).typed("fresh").key(KeyCode::Enter);
assert!(scratch.path().join("repositories/fresh/.git").is_dir());
assert_eq!(
d.app.repository().map(|r| r.name.clone()),
Some("fresh".into())
);
assert!(
d.app.blocked.is_some(),
"a repository with no commits read as ready"
);
d.ctrl('x').key(KeyCode::Char('x'));
d.shows("no commits");
d.shows("Commit what is here");
}
#[test]
fn a_task_typed_into_the_box_runs_and_is_recorded() {
let _guard = exclusive();
let scratch = project("run", 0);
let mut d = Driver::open(scratch.path());
d.shows("Write a task below");
d.hides("Last asked here");
d.task("write a file");
let screen = d.screen();
for expected in [
"write a file",
"isolate",
"prepare",
"author",
"reading the repository",
"gate",
"check",
"review",
"APPROVE",
"approved",
] {
assert!(screen.contains(expected), "no {expected:?} on:\n{screen}");
}
let run_id = d
.last()
.finished
.as_ref()
.map(|f| f.run_id.clone())
.expect("a finished run");
assert!(
scratch
.path()
.join(".ostraka/runs")
.join(&run_id)
.join("record.json")
.is_file()
);
assert_eq!(d.app.runs.len(), 1);
}
#[test]
fn the_second_task_starts_where_the_first_one_finished() {
let _guard = exclusive();
let scratch = project("thread", 0);
let mut d = Driver::open(scratch.path());
assert_eq!(d.app.thread().base_ref, "HEAD");
d.task("write a file");
let first = d
.last()
.finished
.as_ref()
.map(|f| f.run_id.clone())
.expect("a finished run");
assert!(d.last().approved(), "the first run was not approved");
assert_eq!(d.app.thread().base_ref, format!("ostraka/{first}"));
d.shows("on ");
d.task("write it again");
assert_eq!(d.app.thread().turns.len(), 2);
let second = d
.last()
.finished
.as_ref()
.map(|f| f.run_id.clone())
.expect("a second run");
let out = Command::new("git")
.args(["log", "--format=%H", &format!("ostraka/{second}")])
.current_dir(repo_of(&scratch))
.output()
.expect("git log");
let commits = String::from_utf8_lossy(&out.stdout).lines().count();
assert_eq!(commits, 3, "the chain did not build on the first run");
}
#[test]
fn a_refused_run_is_not_the_ground_the_next_one_stands_on() {
let _guard = exclusive();
let scratch = project("refused", 0);
std::fs::write(
scratch.path().join(".ostraka/ostraka.toml"),
"[gate]\nchecks = [{ name = \"check\", cmd = \"false\", required = true }]\n\n\
[gate.review]\nmust_differ_from_author = true\n",
)
.expect("config");
let mut d = Driver::open(scratch.path());
d.task("write a file");
assert!(!d.last().approved());
assert_eq!(
d.app.thread().base_ref,
"HEAD",
"the chain advanced through a refusal"
);
d.shows("FAIL");
}
#[test]
fn the_command_line_continues_a_run_the_way_the_browser_does() {
let _guard = exclusive();
let scratch = project("cli-thread", 0);
let workspace = Workspace::at(scratch.path());
let first = crate::run::execute(
&workspace,
&crate::run::Args::for_task("write a file".into()),
None,
)
.expect("the first run completes");
assert!(first.approved(), "the first run was not approved");
let mut args = crate::run::Args::for_task("write it again".into());
args.from = Some(first.record.run_id.clone());
let second = crate::run::execute(&workspace, &args, None).expect("the second run completes");
assert!(second.approved(), "the second run was not approved");
let out = Command::new("git")
.args([
"log",
"--format=%H",
&format!("ostraka/{}", second.record.run_id),
])
.current_dir(repo_of(&scratch))
.output()
.expect("git log");
let commits = String::from_utf8_lossy(&out.stdout).lines().count();
assert_eq!(commits, 3, "the chain did not build on the first run");
}
#[test]
fn the_command_line_will_not_continue_a_refused_run() {
let _guard = exclusive();
let scratch = project("cli-refused", 0);
std::fs::write(
scratch.path().join(".ostraka/ostraka.toml"),
"[gate]\nchecks = [{ name = \"check\", cmd = \"false\", required = true }]\n\n\
[gate.review]\nmust_differ_from_author = true\n",
)
.expect("config");
let workspace = Workspace::at(scratch.path());
let refused = crate::run::execute(
&workspace,
&crate::run::Args::for_task("write a file".into()),
None,
)
.expect("the run completes");
assert!(!refused.approved(), "the gate let it through");
let mut args = crate::run::Args::for_task("carry on".into());
args.from = Some(refused.record.run_id.clone());
let refusal = match crate::run::execute(&workspace, &args, None) {
Ok(_) => panic!("a refused run was continued"),
Err(e) => e,
};
let said = refusal.to_string();
assert!(
said.contains("not a base to build on"),
"the reason was not given: {said}"
);
}
#[test]
fn continuing_a_run_nobody_recorded_says_so() {
let _guard = exclusive();
let scratch = project("cli-nosuch", 0);
let workspace = Workspace::at(scratch.path());
let mut args = crate::run::Args::for_task("carry on".into());
args.from = Some("t-never-happened".into());
let refusal = match crate::run::execute(&workspace, &args, None) {
Ok(_) => panic!("a run nobody recorded was continued"),
Err(e) => e,
};
assert!(
refusal.to_string().contains("no run"),
"{}",
refusal.to_string()
);
}
#[test]
fn choosing_an_agent_the_workspace_has_not_written_writes_it_first() {
let _guard = exclusive();
let scratch = project("agents-adopt", 0);
let dir = scratch.path();
let mut d = Driver::open(dir);
d.ctrl('x').key(KeyCode::Char('a'));
assert_eq!(d.app.dialog, Some(Dialog::Agents));
d.app.agents.push(super::view::Agent {
id: "codex".into(),
ready: true,
note: "codex-cli 0.0.0".into(),
configured: false,
});
d.app.pick = d.app.agents.len() - 1;
assert!(
!dir.join(".ostraka/adapters/codex.toml").exists(),
"the fixture already had the profile this is about writing"
);
d.shows("not configured");
d.key(KeyCode::Char('a'));
assert_eq!(d.app.thread().adapter.as_deref(), Some("codex"));
assert!(
dir.join(".ostraka/adapters/codex.toml").is_file(),
"the profile was named but never written"
);
let written = std::fs::read_to_string(dir.join(".ostraka/adapters/codex.toml")).expect("read");
let shipped = crate::init::TEMPLATES
.iter()
.find(|(name, _)| *name == "codex.toml")
.map(|(_, text)| *text)
.expect("shipped");
assert_eq!(written, shipped);
let now = d
.app
.agents
.iter()
.find(|a| a.id == "codex")
.expect("still listed");
assert!(
now.configured,
"the written profile is still offered as missing"
);
}
#[test]
fn only_what_is_installed_is_ever_offered() {
let _guard = exclusive();
let scratch = project("agents-offer", 0);
let mut d = Driver::open(scratch.path());
d.ctrl('x').key(KeyCode::Char('a'));
assert!(
d.app.agents.iter().any(|a| a.configured),
"the workspace's own profiles are missing from its own list"
);
assert!(
d.app
.agents
.iter()
.filter(|a| !a.configured)
.all(|a| a.ready),
"something not installed was offered"
);
}
#[test]
fn the_browser_shows_what_a_run_left_before_it_removes_any_of_it() {
let _guard = exclusive();
let scratch = project("prune", 0);
let mut d = Driver::open(scratch.path());
std::fs::write(
scratch.path().join(".ostraka/ostraka.toml"),
"[gate]\nchecks = [{ name = \"check\", cmd = \"false\", required = true }]\n\n\
[gate.review]\nmust_differ_from_author = true\n",
)
.expect("config");
d.task("write a file");
assert!(!d.last().approved(), "the gate let it through");
d.ctrl('x').key(KeyCode::Char('u'));
assert_eq!(d.app.dialog, Some(Dialog::Prune));
assert_eq!(
d.app.leftovers.len(),
1,
"the refused run's worktree is not listed"
);
let left = d.app.leftovers[0].path.clone();
assert!(left.is_dir(), "the fixture is wrong: nothing is there");
d.shows("Branches and run records are untouched");
d.key(KeyCode::Esc);
assert!(left.is_dir(), "esc removed something");
d.ctrl('x').key(KeyCode::Char('u'));
d.key(KeyCode::Char('y'));
assert!(!left.exists(), "y did not remove the worktree");
assert_eq!(d.app.dialog, None);
let run_id = d
.last()
.finished
.as_ref()
.map(|f| f.run_id.clone())
.expect("a finished run");
let out = Command::new("git")
.args(["rev-parse", "--verify", &format!("ostraka/{run_id}")])
.current_dir(repo_of(&scratch))
.output()
.expect("git");
assert!(out.status.success(), "the branch went with the worktree");
}
fn out_of_context(dir: &Path) {
std::fs::write(
dir.join("writer.sh"),
"#!/bin/sh\n\
case \"$1\" in --probe) echo ok; exit 0;; esac\n\
echo 'reading the repository'\n\
printf 'half a line\\n' >> wrote.txt\n\
echo 'Error: prompt is too long: 210000 tokens > 200000 maximum' >&2\n\
exit 1\n",
)
.expect("script");
}
#[test]
fn a_task_whose_agent_runs_out_of_tokens_is_refused_and_says_why() {
let _guard = exclusive();
let scratch = project("out-of-tokens", 0);
out_of_context(scratch.path());
let mut d = Driver::open(scratch.path());
d.app.thread_mut().adapter = Some("writer".into());
d.app.thread_mut().review_adapter = Some("reader".into());
d.task("write something long");
assert!(!d.last().approved(), "a half-written change was approved");
assert_eq!(
d.app.thread().base_ref,
"HEAD",
"the chain stood on a change that ran out"
);
let screen = d.screen();
assert!(screen.contains("prompt is too long"), "{screen}");
assert!(
screen.contains("could not run"),
"the outcome did not say why:\n{screen}"
);
assert!(
!screen.contains("review"),
"a reviewer was called on half a change:\n{screen}"
);
let left = std::fs::read_dir(scratch.path().join(".ostraka/worktrees"))
.expect("a worktrees directory")
.filter_map(|e| e.ok())
.find(|e| e.path().join("wrote.txt").is_file());
assert!(left.is_some(), "the evidence was thrown away");
}
#[test]
fn a_run_can_be_stopped_from_the_browser_and_is_not_called_a_verdict() {
let _guard = exclusive();
let scratch = project("stop", 30);
let mut d = Driver::open(scratch.path());
d.typed("a task nobody wants finished").key(KeyCode::Enter);
d.ctrl('x').key(KeyCode::Char('s'));
assert!(d.app.thread().live.as_ref().expect("a session").stopping);
d.shows("stopping");
d.until("the run to stop", |app| app.thread().turns.len() == 1);
d.shows("stopped by the operator");
ostraka_adapter::interrupt::clear();
}
#[test]
fn quitting_during_a_run_waits_for_it_rather_than_walking_away() {
let _guard = exclusive();
let scratch = project("quit", 30);
let mut d = Driver::open(scratch.path());
d.typed("a task interrupted by leaving").key(KeyCode::Enter);
d.ctrl('c');
assert_eq!(d.app.dialog, Some(Dialog::Leaving));
d.shows("A run is going");
d.key(KeyCode::Char('y'));
assert!(!d.app.quit, "the browser left while a run was going");
assert!(d.app.leaving);
d.shows("stopping the run");
d.until("the browser to leave", |app| app.quit);
let runs = std::fs::read_dir(scratch.path().join(".ostraka/runs"))
.expect("a runs directory")
.count();
assert_eq!(runs, 1, "the run was abandoned without a record");
ostraka_adapter::interrupt::clear();
}
#[test]
fn a_run_is_looked_up_in_a_dialog_and_read_on_a_screen_of_its_own() {
let _guard = exclusive();
let scratch = project("lookup", 0);
let mut d = Driver::open(scratch.path());
d.task("write a file");
d.task("write it again");
d.ctrl('x').key(KeyCode::Char('l'));
assert_eq!(d.app.dialog, Some(Dialog::Runs));
d.shows("write a file");
d.shows("enter opens it");
d.typed("again");
d.shows("1 of 2 runs");
d.hides("write a file");
d.key(KeyCode::Enter);
assert_eq!(d.app.dialog, None);
assert_eq!(d.app.screen, Screen::Record);
assert_eq!(d.app.focus, Focus::Keys);
d.shows("check");
d.key(KeyCode::Tab);
d.shows("reading the repository");
d.key(KeyCode::Tab);
d.shows("written by the agent");
d.key(KeyCode::Esc);
assert_eq!(d.app.screen, Screen::Work);
assert_eq!(d.app.focus, Focus::Prompt);
assert!(!d.app.quit);
}
#[test]
fn the_agents_dialog_names_who_writes_and_who_reviews() {
let _guard = exclusive();
let scratch = project("agents", 0);
let mut d = Driver::open(scratch.path());
d.ctrl('x').key(KeyCode::Char('a'));
assert_eq!(d.app.dialog, Some(Dialog::Agents));
d.shows("automatic");
d.shows("writer");
d.shows("reader");
d.key(KeyCode::Char('a'));
d.shows("writes");
assert!(d.app.thread().adapter.is_some());
d.key(KeyCode::Down).key(KeyCode::Char('r'));
assert!(d.app.thread().review_adapter.is_some());
assert_ne!(d.app.thread().adapter, d.app.thread().review_adapter);
d.key(KeyCode::Esc);
let author = d.app.thread().adapter.clone().expect("an author");
d.task("write a file");
assert_eq!(
d.app.current().map(|r| r.adapter.clone()),
Some(author),
"the named author did not write it"
);
}
#[test]
fn a_fresh_thread_goes_back_to_head() {
let _guard = exclusive();
let scratch = project("fresh", 0);
let mut d = Driver::open(scratch.path());
d.task("write a file");
assert!(d.app.thread().continuing());
d.ctrl('x').key(KeyCode::Char('f'));
assert_eq!(d.app.thread().base_ref, "HEAD");
assert!(d.app.thread().turns.is_empty());
d.shows("Write a task below");
d.shows("Last asked here");
d.shows("write a file");
}
#[test]
fn the_palette_reaches_a_command_by_name_and_the_leader_by_letter() {
let scratch = Scratch::new("commands");
std::fs::create_dir_all(scratch.path().join("repositories/work")).expect("repository");
std::fs::write(
scratch.path().join("repositories/work/Cargo.toml"),
"[package]\n",
)
.expect("write");
let mut d = Driver::open(scratch.path());
d.ctrl('k').typed("keys").key(KeyCode::Enter);
assert_eq!(d.app.dialog, Some(Dialog::Keys));
d.shows("promote a record");
d.shows("ctrl-x");
d.key(KeyCode::Esc);
d.ctrl('x').key(KeyCode::Char('h'));
assert_eq!(d.app.dialog, Some(Dialog::Keys));
d.key(KeyCode::Esc);
assert_eq!(d.app.dialog, None);
}