Add first snapshot tests using insta (#2813)

This commit is contained in:
Christoph Rüßler 2026-04-07 11:33:43 +02:00 committed by GitHub
parent b1db21e10a
commit 7c538e3873
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 412 additions and 118 deletions

38
Cargo.lock generated
View file

@ -498,6 +498,18 @@ dependencies = [
"static_assertions",
]
[[package]]
name = "console"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"windows-sys 0.59.0",
]
[[package]]
name = "const-oid"
version = "0.9.6"
@ -910,6 +922,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@ -1314,7 +1332,9 @@ dependencies = [
"filetreelist",
"fuzzy-matcher",
"gh-emoji",
"git2-testing",
"indexmap",
"insta",
"itertools",
"log",
"notify",
@ -2408,6 +2428,18 @@ dependencies = [
"generic-array",
]
[[package]]
name = "insta"
version = "1.44.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5c943d4415edd8153251b6f197de5eb1640e56d84e8d9159bea190421c73698"
dependencies = [
"console",
"once_cell",
"regex",
"similar",
]
[[package]]
name = "instability"
version = "0.3.6"
@ -3881,6 +3913,12 @@ dependencies = [
"rand_core",
]
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "simplelog"
version = "0.12.2"

View file

@ -76,6 +76,8 @@ which = "8.0"
[dev-dependencies]
env_logger = "0.11"
git2-testing = { path = "./git2-testing" }
insta = { version = "1.41.0", features = ["filters"] }
pretty_assertions = "1.4"
tempfile = "3"

View file

@ -20,13 +20,18 @@ pub fn repo_init_empty() -> (TempDir, Repository) {
(td, repo)
}
/// initialize test repo in temp path with an empty first commit
pub fn repo_init() -> (TempDir, Repository) {
/// initialize test repo in temp path with given suffix and an empty first commit
pub fn repo_init_suffix<T: AsRef<std::ffi::OsStr>>(
suffix: Option<T>,
) -> (TempDir, Repository) {
init_log();
sandbox_config_files();
let td = TempDir::new().unwrap();
let td = match suffix {
Some(suffix) => TempDir::with_suffix(suffix).unwrap(),
None => TempDir::new().unwrap(),
};
let repo = Repository::init(td.path()).unwrap();
{
let mut config = repo.config().unwrap();
@ -45,6 +50,11 @@ pub fn repo_init() -> (TempDir, Repository) {
(td, repo)
}
/// initialize test repo in temp path with an empty first commit
pub fn repo_init() -> (TempDir, Repository) {
repo_init_suffix::<&std::ffi::OsStr>(None)
}
// init log
fn init_log() {
let _ = env_logger::builder()

290
src/gitui.rs Normal file
View file

@ -0,0 +1,290 @@
use std::time::Instant;
use anyhow::Result;
use asyncgit::{sync::utils::repo_work_dir, AsyncGitNotification};
use crossbeam_channel::{never, tick, unbounded, Receiver};
use scopetime::scope_time;
#[cfg(test)]
use crossterm::event::{KeyCode, KeyModifiers};
use crate::{
app::{App, QuitState},
args::CliArgs,
draw,
input::{Input, InputEvent, InputState},
keys::KeyConfig,
select_event,
spinner::Spinner,
ui::style::Theme,
watcher::RepoWatcher,
AsyncAppNotification, AsyncNotification, QueueEvent, Updater,
SPINNER_INTERVAL, TICK_INTERVAL,
};
pub struct Gitui {
app: crate::app::App,
rx_input: Receiver<InputEvent>,
rx_git: Receiver<AsyncGitNotification>,
rx_app: Receiver<AsyncAppNotification>,
rx_ticker: Receiver<Instant>,
rx_watcher: Receiver<()>,
}
impl Gitui {
pub(crate) fn new(
cliargs: CliArgs,
theme: Theme,
key_config: &KeyConfig,
updater: Updater,
) -> Result<Self, anyhow::Error> {
let (tx_git, rx_git) = unbounded();
let (tx_app, rx_app) = unbounded();
let input = Input::new();
let (rx_ticker, rx_watcher) = match updater {
Updater::NotifyWatcher => {
let repo_watcher = RepoWatcher::new(
repo_work_dir(&cliargs.repo_path)?.as_str(),
);
(never(), repo_watcher.receiver())
}
Updater::Ticker => (tick(TICK_INTERVAL), never()),
};
let app = App::new(
cliargs,
tx_git,
tx_app,
input.clone(),
theme,
key_config.clone(),
)?;
Ok(Self {
app,
rx_input: input.receiver(),
rx_git,
rx_app,
rx_ticker,
rx_watcher,
})
}
pub(crate) fn run_main_loop<B: ratatui::backend::Backend>(
&mut self,
terminal: &mut ratatui::Terminal<B>,
) -> Result<QuitState, anyhow::Error>
where
<B as ratatui::backend::Backend>::Error:
'static + Send + Sync,
{
let spinner_ticker = tick(SPINNER_INTERVAL);
let mut spinner = Spinner::default();
let mut first_update = true;
self.app.update()?;
loop {
let event = if first_update {
first_update = false;
QueueEvent::Notify
} else {
select_event(
&self.rx_input,
&self.rx_git,
&self.rx_app,
&self.rx_ticker,
&self.rx_watcher,
&spinner_ticker,
)?
};
{
if matches!(event, QueueEvent::SpinnerUpdate) {
spinner.update();
spinner.draw(terminal)?;
continue;
}
scope_time!("loop");
match event {
QueueEvent::InputEvent(ev) => {
if matches!(
ev,
InputEvent::State(InputState::Polling)
) {
//Note: external ed closed, we need to re-hide cursor
terminal.hide_cursor()?;
}
self.app.event(ev)?;
}
QueueEvent::Tick | QueueEvent::Notify => {
self.app.update()?;
}
QueueEvent::AsyncEvent(ev) => {
if !matches!(
ev,
AsyncNotification::Git(
AsyncGitNotification::FinishUnchanged
)
) {
self.app.update_async(ev)?;
}
}
QueueEvent::SpinnerUpdate => unreachable!(),
}
self.draw(terminal)?;
spinner.set_state(self.app.any_work_pending());
spinner.draw(terminal)?;
if self.app.is_quit() {
break;
}
}
}
Ok(self.app.quit_state())
}
fn draw<B: ratatui::backend::Backend>(
&self,
terminal: &mut ratatui::Terminal<B>,
) -> Result<(), B::Error> {
draw(terminal, &self.app)
}
#[cfg(test)]
fn update_async(&mut self, event: crate::AsyncNotification) {
self.app.update_async(event).unwrap();
}
#[cfg(test)]
fn input_event(
&mut self,
code: KeyCode,
modifiers: KeyModifiers,
) {
let event = crossterm::event::KeyEvent::new(code, modifiers);
self.app
.event(crate::input::InputEvent::Input(
crossterm::event::Event::Key(event),
))
.unwrap();
}
#[cfg(test)]
fn wait_for_async_git_notification(
&self,
expected: AsyncGitNotification,
) {
loop {
let actual = self
.rx_git
.recv_timeout(std::time::Duration::from_millis(100))
.unwrap();
if actual == expected {
break;
}
}
}
#[cfg(test)]
fn update(&mut self) {
self.app.update().unwrap();
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use asyncgit::{sync::RepoPath, AsyncGitNotification};
use crossterm::event::{KeyCode, KeyModifiers};
use git2_testing::repo_init_suffix;
use insta::assert_snapshot;
use ratatui::{backend::TestBackend, Terminal};
use crate::{
args::CliArgs, gitui::Gitui, keys::KeyConfig,
ui::style::Theme, AsyncNotification, Updater,
};
// Macro adapted from: https://insta.rs/docs/cmd/
macro_rules! apply_common_filters {
{} => {
let mut settings = insta::Settings::clone_current();
// Windows and MacOS
// We don't match on the full path, but on the suffix we pass to `repo_init_suffix` below.
settings.add_filter(r" *\[…\]\S+-insta/?", "[TEMP_FILE]");
// Linux Temp Folder
settings.add_filter(r" */tmp/\.tmp\S+-insta/", "[TEMP_FILE]");
// Commit ids that follow a vertical bar
settings.add_filter(r"│[a-z0-9]{7} ", "│[AAAAA] ");
let _bound = settings.bind_to_scope();
}
}
#[test]
fn gitui_starts() {
apply_common_filters!();
let (temp_dir, _repo) = repo_init_suffix(Some("-insta"));
let path: RepoPath = temp_dir.path().to_str().unwrap().into();
let cliargs = CliArgs {
theme: PathBuf::from("theme.ron"),
select_file: None,
repo_path: path,
notify_watcher: false,
key_bindings_path: None,
key_symbols_path: None,
};
let theme = Theme::init(&PathBuf::new());
let key_config = KeyConfig::default();
let mut gitui =
Gitui::new(cliargs, theme, &key_config, Updater::Ticker)
.unwrap();
let mut terminal =
Terminal::new(TestBackend::new(90, 12)).unwrap();
gitui.draw(&mut terminal).unwrap();
assert_snapshot!("app_loading", terminal.backend());
let event =
AsyncNotification::Git(AsyncGitNotification::Status);
gitui.update_async(event);
gitui.draw(&mut terminal).unwrap();
assert_snapshot!("app_loading_finished", terminal.backend());
gitui.input_event(KeyCode::Char('2'), KeyModifiers::empty());
gitui.input_event(
key_config.keys.tab_log.code,
key_config.keys.tab_log.modifiers,
);
gitui.wait_for_async_git_notification(
AsyncGitNotification::Log,
);
gitui.update();
gitui.draw(&mut terminal).unwrap();
assert_snapshot!(
"app_log_tab_showing_one_commit",
terminal.backend()
);
}
}

View file

@ -65,6 +65,7 @@ mod bug_report;
mod clipboard;
mod cmdbar;
mod components;
mod gitui;
mod input;
mod keys;
mod notify_mutex;
@ -85,12 +86,9 @@ use crate::{
};
use anyhow::{anyhow, bail, Result};
use app::QuitState;
use asyncgit::{
sync::{utils::repo_work_dir, RepoPath},
AsyncGitNotification,
};
use asyncgit::{sync::RepoPath, AsyncGitNotification};
use backtrace::Backtrace;
use crossbeam_channel::{never, tick, unbounded, Receiver, Select};
use crossbeam_channel::{Receiver, Select};
use crossterm::{
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen,
@ -98,12 +96,11 @@ use crossterm::{
},
ExecutableCommand,
};
use input::{Input, InputEvent, InputState};
use gitui::Gitui;
use input::InputEvent;
use keys::KeyConfig;
use ratatui::backend::CrosstermBackend;
use scopeguard::defer;
use scopetime::scope_time;
use spinner::Spinner;
use std::{
io::{self, Stdout},
panic,
@ -111,7 +108,6 @@ use std::{
time::{Duration, Instant},
};
use ui::style::Theme;
use watcher::RepoWatcher;
type Terminal = ratatui::Terminal<CrosstermBackend<io::Stdout>>;
@ -187,7 +183,6 @@ fn main() -> Result<()> {
let mut terminal =
start_terminal(io::stdout(), &cliargs.repo_path)?;
let input = Input::new();
let updater = if cliargs.notify_watcher {
Updater::NotifyWatcher
@ -202,8 +197,7 @@ fn main() -> Result<()> {
app_start,
args.clone(),
theme.clone(),
key_config.clone(),
&input,
&key_config,
updater,
&mut terminal,
)?;
@ -230,106 +224,15 @@ fn run_app(
app_start: Instant,
cliargs: CliArgs,
theme: Theme,
key_config: KeyConfig,
input: &Input,
key_config: &KeyConfig,
updater: Updater,
terminal: &mut Terminal,
) -> Result<QuitState, anyhow::Error> {
let (tx_git, rx_git) = unbounded();
let (tx_app, rx_app) = unbounded();
let rx_input = input.receiver();
let (rx_ticker, rx_watcher) = match updater {
Updater::NotifyWatcher => {
let repo_watcher = RepoWatcher::new(
repo_work_dir(&cliargs.repo_path)?.as_str(),
);
(never(), repo_watcher.receiver())
}
Updater::Ticker => (tick(TICK_INTERVAL), never()),
};
let spinner_ticker = tick(SPINNER_INTERVAL);
let mut app = App::new(
cliargs,
tx_git,
tx_app,
input.clone(),
theme,
key_config,
)?;
let mut spinner = Spinner::default();
let mut first_update = true;
let mut gitui = Gitui::new(cliargs, theme, key_config, updater)?;
log::trace!("app start: {} ms", app_start.elapsed().as_millis());
loop {
let event = if first_update {
first_update = false;
QueueEvent::Notify
} else {
select_event(
&rx_input,
&rx_git,
&rx_app,
&rx_ticker,
&rx_watcher,
&spinner_ticker,
)?
};
{
if matches!(event, QueueEvent::SpinnerUpdate) {
spinner.update();
spinner.draw(terminal)?;
continue;
}
scope_time!("loop");
match event {
QueueEvent::InputEvent(ev) => {
if matches!(
ev,
InputEvent::State(InputState::Polling)
) {
//Note: external ed closed, we need to re-hide cursor
terminal.hide_cursor()?;
}
app.event(ev)?;
}
QueueEvent::Tick | QueueEvent::Notify => {
app.update()?;
}
QueueEvent::AsyncEvent(ev) => {
if !matches!(
ev,
AsyncNotification::Git(
AsyncGitNotification::FinishUnchanged
)
) {
app.update_async(ev)?;
}
}
QueueEvent::SpinnerUpdate => unreachable!(),
}
draw(terminal, &app)?;
spinner.set_state(app.any_work_pending());
spinner.draw(terminal)?;
if app.is_quit() {
break;
}
}
}
Ok(app.quit_state())
gitui.run_main_loop(terminal)
}
fn setup_terminal() -> Result<()> {
@ -353,7 +256,10 @@ fn shutdown_terminal() {
}
}
fn draw(terminal: &mut Terminal, app: &App) -> io::Result<()> {
fn draw<B: ratatui::backend::Backend>(
terminal: &mut ratatui::Terminal<B>,
app: &App,
) -> Result<(), B::Error> {
if app.requires_redraw() {
terminal.clear()?;
}

View file

@ -0,0 +1,17 @@
---
source: src/gitui.rs
expression: terminal.backend()
snapshot_kind: text
---
" Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5][TEMP_FILE] "
" ──────────────────────────────────────────────────────────────────────────────────────── "
"┌Unstaged Changes───────────────────────────┐┌Diff: ─────────────────────────────────────┐"
"│Loading ... ││ │"
"│ ││ │"
"│ ││ │"
"└───────────────────────────────────{master}┘│ │"
"┌Staged Changes─────────────────────────────┐│ │"
"│Loading ... ││ │"
"│ ││ │"
"└───────────────────────────────────────────┘└───────────────────────────────────────────┘"
" "

View file

@ -0,0 +1,17 @@
---
source: src/gitui.rs
expression: terminal.backend()
snapshot_kind: text
---
" Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5][TEMP_FILE] "
" ──────────────────────────────────────────────────────────────────────────────────────── "
"┌Unstaged Changes───────────────────────────┐┌Diff: ─────────────────────────────────────┐"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"└───────────────────────────────────{master}┘│ │"
"┌Staged Changes─────────────────────────────┐│ │"
"│ ││ │"
"│ ││ │"
"└───────────────────────────────────────────┘└───────────────────────────────────────────┘"
"Branches [b] Push [p] Fetch [⇧F] Pull [f] Undo Commit [⇧U] Submodules [⇧S] more [.]"

View file

@ -0,0 +1,17 @@
---
source: src/gitui.rs
expression: terminal.backend()
snapshot_kind: text
---
" Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5][TEMP_FILE] "
" ──────────────────────────────────────────────────────────────────────────────────────── "
"┌Commit 1/1──────────────────────────────────────────────────────────────────────────────┐"
"│[AAAAA] <1m ago name initial █"
"│ ║"
"│ ║"
"│ ║"
"│ ║"
"│ ║"
"│ ║"
"└────────────────────────────────────────────────────────────────────────────────────────┘"
"Scroll [↑↓] Mark [˽] Details [⏎] Branches [b] Compare [⇧C] Copy Hash [y] Tag [t] more [.]"

View file

@ -1,8 +1,5 @@
use ratatui::{
backend::{Backend, CrosstermBackend},
Terminal,
};
use std::{cell::Cell, char, io};
use ratatui::{backend::Backend, Terminal};
use std::{cell::Cell, char};
// static SPINNER_CHARS: &[char] = &['◢', '◣', '◤', '◥'];
// static SPINNER_CHARS: &[char] = &['⢹', '⢺', '⢼', '⣸', '⣇', '⡧', '⡗', '⡏'];
@ -39,10 +36,10 @@ impl Spinner {
}
/// draws or removes spinner char depending on `pending` state
pub fn draw(
pub fn draw<B: ratatui::backend::Backend>(
&self,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> io::Result<()> {
terminal: &mut Terminal<B>,
) -> Result<(), B::Error> {
let idx = self.idx;
let char_to_draw =