use std::path::PathBuf;
use std::process::{Command, Output};
const MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR");
const SHUIRE_BIN: &str = env!("CARGO_BIN_EXE_shuire");
fn shell_quote(arg: &str) -> String {
let safe = arg
.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_./=:@".contains(c));
if safe {
arg.to_string()
} else {
format!("'{}'", arg.replace('\'', r"'\''"))
}
}
fn tuistory_raw(args: &[&str]) -> Option<Output> {
Command::new("tuistory").args(args).output().ok()
}
fn tuistory_ok(args: &[&str]) -> String {
let out = tuistory_raw(args).expect("tuistory must be installed and on PATH");
assert!(
out.status.success(),
"`tuistory {}` failed\nstdout: {}\nstderr: {}",
args.join(" "),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn tuistory_available() -> bool {
Command::new("tuistory")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
struct Session {
name: String,
}
impl Session {
fn launch(name: &str, extra_args: &[&str]) -> Self {
let sample = format!("{MANIFEST_DIR}/testdata/sample.diff");
let mut cmd = format!("{SHUIRE_BIN} --from-file {sample} --theme dark --no-emoji --clean");
for a in extra_args {
cmd.push(' ');
cmd.push_str(&shell_quote(a));
}
Self::launch_raw(name, &cmd)
}
fn launch_raw(name: &str, cmd: &str) -> Self {
let _ = tuistory_raw(&["close", "-s", name]);
tuistory_ok(&["launch", cmd, "-s", name, "--cols", "140", "--rows", "40"]);
Self { name: name.into() }
}
fn wait(&self, pat: &str) {
tuistory_ok(&["wait", pat, "-s", &self.name, "--timeout", "10000"]);
}
fn wait_idle(&self) {
let _ = tuistory_raw(&["wait-idle", "-s", &self.name]);
}
fn press(&self, keys: &[&str]) {
let mut args: Vec<&str> = vec!["press"];
args.extend_from_slice(keys);
args.extend_from_slice(&["-s", &self.name]);
tuistory_ok(&args);
}
fn type_text(&self, text: &str) {
tuistory_ok(&["type", text, "-s", &self.name]);
}
fn snapshot(&self) -> String {
tuistory_ok(&["snapshot", "-s", &self.name, "--trim"])
}
}
impl Drop for Session {
fn drop(&mut self) {
let _ = tuistory_raw(&["close", "-s", &self.name]);
}
}
macro_rules! require_tuistory {
() => {
if !tuistory_available() {
eprintln!("skipping: `tuistory` is not installed");
return;
}
};
}
#[test]
#[ignore]
fn scenario_launch_renders_file_list() {
require_tuistory!();
let s = Session::launch("shuire-e2e-launch", &[]);
s.wait("FILES");
s.wait("README.md");
let snap = s.snapshot();
for expected in ["README.md", "Cargo.toml", "src"] {
assert!(
snap.contains(expected),
"expected `{expected}` in snapshot:\n{snap}"
);
}
}
#[test]
#[ignore]
fn scenario_tab_moves_to_next_file() {
require_tuistory!();
let s = Session::launch("shuire-e2e-nav", &[]);
s.wait("README.md");
s.press(&["tab"]);
s.wait("Cargo.toml");
s.press(&["tab"]);
s.wait("main.rs");
s.press(&["shift", "tab"]);
s.wait_idle();
let snap = s.snapshot();
assert!(
snap.contains("Cargo.toml"),
"expected Cargo.toml after Shift-Tab:\n{snap}"
);
}
#[test]
#[ignore]
fn scenario_toggle_split_view() {
require_tuistory!();
let s = Session::launch("shuire-e2e-split", &["--mode", "unified"]);
s.wait("README.md");
s.wait_idle();
let unified = s.snapshot();
s.press(&["s"]);
s.wait_idle();
let split = s.snapshot();
assert_ne!(
unified, split,
"toggling split view should change the rendered layout"
);
s.press(&["s"]);
s.wait_idle();
let unified_again = s.snapshot();
assert_ne!(split, unified_again);
}
#[test]
#[ignore]
fn scenario_help_overlay_toggles() {
require_tuistory!();
let s = Session::launch("shuire-e2e-help", &[]);
s.wait("FILES");
s.type_text("?");
s.wait("Navigation");
let with_help = s.snapshot();
assert!(
with_help.contains("朱入レ"),
"expected help body:\n{with_help}"
);
s.press(&["esc"]);
s.wait_idle();
let dismissed = s.snapshot();
assert!(
!dismissed.contains("Navigation"),
"help overlay should be dismissed:\n{dismissed}"
);
}
#[test]
#[ignore]
fn scenario_file_filter() {
require_tuistory!();
let s = Session::launch("shuire-e2e-filter", &[]);
s.wait("README.md");
s.press(&["h"]);
s.type_text("/");
s.wait_idle();
s.type_text("api");
s.wait_idle();
let snap = s.snapshot();
assert!(
snap.contains("api"),
"expected /api filter marker in header:\n{snap}"
);
assert!(
snap.contains("handlers.rs") || snap.contains("routes.rs"),
"expected matching api/* files to remain:\n{snap}"
);
s.press(&["esc"]);
s.wait_idle();
}
fn scratch_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("shuire-e2e-{}-{}", tag, std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create scratch dir");
dir
}
#[test]
#[ignore]
fn scenario_stdout_emits_final_comments() {
require_tuistory!();
let scratch = scratch_dir("stdout");
let script_path = scratch.join("run.sh");
let marker = "e2e-stdout-marker-7a3b";
let comment_json = format!(r#"{{"file":"README.md","line":1,"body":"{marker}"}}"#);
let sample = format!("{MANIFEST_DIR}/testdata/sample.diff");
let script_body = format!(
"#!/bin/sh\n\
{bin} --from-file {sample} --theme dark --no-emoji --clean \
--comment '{json}'\n\
echo __E2E_STDOUT_DONE__\n",
bin = SHUIRE_BIN,
json = comment_json,
);
std::fs::write(&script_path, script_body).expect("write wrapper script");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))
.expect("chmod +x");
}
let s = Session::launch_raw(
"shuire-e2e-stdout",
&format!("sh {}", script_path.display()),
);
s.wait("README.md");
s.press(&["q"]);
s.wait("__E2E_STDOUT_DONE__");
let snap = s.snapshot();
assert!(
snap.contains(marker),
"expected saved comment body on restored main screen:\n{snap}"
);
assert!(
snap.contains("README.md:"),
"expected location header on restored main screen:\n{snap}"
);
}
#[test]
#[ignore]
fn scenario_clipboard_yank_all() {
require_tuistory!();
let mut clipboard = match arboard::Clipboard::new() {
Ok(cb) => cb,
Err(e) => {
eprintln!("skipping: clipboard backend unavailable ({e})");
return;
}
};
let sentinel = "e2e-clipboard-pre-sentinel";
let _ = clipboard.set_text(sentinel);
let marker = "e2e-clipboard-marker-8d4f";
let comment_json = format!(r#"[{{"file":"README.md","line":1,"body":"{marker}"}}]"#);
let s = Session::launch("shuire-e2e-clipboard", &["--comment", &comment_json]);
s.wait("README.md");
s.press(&["shift", "y"]);
s.wait("Copied");
s.wait_idle();
let got = clipboard.get_text().expect("read clipboard after Y");
assert_ne!(got, sentinel, "clipboard was never updated by `Y`");
assert!(
got.contains(marker),
"expected marker in clipboard, got: {got:?}"
);
assert!(
got.contains("README.md:"),
"expected location header in clipboard, got: {got:?}"
);
}
#[test]
#[ignore]
fn scenario_add_comment_roundtrip() {
require_tuistory!();
let s = Session::launch("shuire-e2e-comment", &[]);
s.wait("README.md");
s.press(&["l"]);
s.press(&["j"]);
s.press(&["i"]);
s.wait("New Comment");
let body = "e2e-roundtrip-marker";
s.type_text(body);
s.wait_idle();
s.press(&["ctrl", "s"]);
s.wait_idle();
let snap = s.snapshot();
assert!(
snap.contains(body),
"saved comment body should appear in the diff pane:\n{snap}"
);
}
#[test]
#[ignore]
fn scenario_search_highlights_match() {
require_tuistory!();
let s = Session::launch("shuire-e2e-search", &[]);
s.wait("README.md");
s.press(&["l"]); s.type_text("/");
s.wait_idle();
s.type_text("enjoy");
s.press(&["enter"]);
s.wait_idle();
let after_search = s.snapshot();
assert!(
after_search.contains("enjoy"),
"match body should still be visible:\n{after_search}"
);
assert!(
after_search.contains("Search: enjoy") || after_search.contains("/enjoy"),
"expected active-search marker in status bar:\n{after_search}"
);
s.press(&["esc"]);
s.wait_idle();
let cleared = s.snapshot();
assert!(
!cleared.contains("Search: enjoy"),
"Esc should clear the search marker:\n{cleared}"
);
}
#[test]
#[ignore]
fn scenario_delete_comment_with_dd() {
require_tuistory!();
let marker = "e2e-delete-marker-5b2c";
let comment_json =
format!(r#"{{"file":".github/workflows/ci.yml","line":1,"body":"{marker}"}}"#);
let s = Session::launch("shuire-e2e-delete-comment", &["--comment", &comment_json]);
s.wait("ci.yml");
s.press(&["l"]);
s.press(&["j"]);
s.wait_idle();
let before = s.snapshot();
assert!(
before.contains(marker),
"seed comment should be visible before delete:\n{before}"
);
s.press(&["d"]);
s.press(&["d"]);
s.wait_idle();
let after = s.snapshot();
assert!(
!after.contains(marker),
"comment body should be gone after dd:\n{after}"
);
assert!(
after.contains("Comment deleted"),
"expected flash toast after delete:\n{after}"
);
}
#[test]
#[ignore]
fn scenario_file_list_toggle_with_shift_f() {
require_tuistory!();
let s = Session::launch("shuire-e2e-filelist", &[]);
s.wait("FILES");
let with_list = s.snapshot();
assert!(with_list.contains("FILES"));
s.press(&["shift", "f"]);
s.wait_idle();
let hidden = s.snapshot();
assert!(
!hidden.contains("FILES"),
"FILES header should be hidden after Shift-F:\n{hidden}"
);
s.press(&["shift", "f"]);
s.wait_idle();
let shown = s.snapshot();
assert!(
shown.contains("FILES"),
"FILES header should reappear:\n{shown}"
);
}