Skip to main content

wisp/
testing.rs

1//! Public testing harness for driving the application model without side effects.
2//!
3//! Prefer [`TestUi`] over reaching for [`App`], [`Renderer`], or a raw
4//! `Terminal<TestBackend>`: it owns all of them and routes input, ACP events,
5//! task settling, and drawing through the same seams the event loop uses.
6
7use crate::app::message::Message;
8use crate::app::{App, AppConfig};
9use crate::attachment::{AttachmentOutcome, PromptAttachment, build_attachments_with};
10use crate::command::{AgentCommand, Command, CommandResult, FilesystemCommand, GitCommand, GitWatchCommand};
11use crate::file_index::{FileEntry, MAX_INDEXED_FILES, file_entries};
12use crate::git_review::{
13    DiffDocument, DiffScope, FileDiff, FileStatus, GitDiffError, GitDiffEvent, GitWatchError, GitWatchEvent,
14    GitWatchResult, StageState,
15};
16pub use crate::renderer::RenderStats;
17use crate::renderer::Renderer;
18use crate::request::RequestId;
19use crate::session::platform::BrowserOpener;
20use crate::session::terminal::inline_viewport_height;
21use crate::session::workspace_status::WorkspaceStatus;
22use crate::settings::UiSettings;
23use crate::surfaces::composer::ComposerLayout;
24use acp_utils::client::AcpEvent;
25use acp_utils::notifications::{
26    AetherCapabilities, SubAgentEvent, SubAgentProgressParams, SubAgentToolRequest, SubAgentToolResult,
27};
28use agent_client_protocol::schema::MaybeUndefined;
29use agent_client_protocol::schema::v2::{self as acp, SessionId, SessionUpdate, ToolCallUpdate};
30use clankerdiff_core::git_patch_from_texts;
31use clankerdiff_git::RepositorySnapshot;
32use clankerdiff_watch::RepositoryState;
33use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
34use ratatui::backend::{Backend, ClearType, TestBackend, WindowSize};
35use ratatui::buffer::{Buffer, Cell};
36use ratatui::layout::{Position, Rect, Size};
37use ratatui::{Terminal, TerminalOptions, Viewport};
38use serde_json::json;
39use std::collections::{BTreeMap, BTreeSet, VecDeque};
40use std::fmt::Write as _;
41use std::path::{Path, PathBuf};
42use std::sync::{Arc, Mutex};
43use std::time::{Duration, Instant};
44use tokio::sync::mpsc::UnboundedReceiver;
45
46/// A deterministic command runner for integration tests.
47///
48/// Commands are recorded in submission order. Tests may enqueue completions for
49/// commands whose results are part of the scenario; commands without a queued
50/// completion are still recorded and produce no state change.
51pub struct FakeExecutor {
52    /// Commands not yet consumed by a test through `next_command`.
53    available: VecDeque<Command>,
54    /// Commands not yet completed by `settle_tasks`.
55    pending: VecDeque<Command>,
56    git: FakeGit,
57    git_watch: Option<GitWatchEvent>,
58    git_watch_started: bool,
59    git_scope: DiffScope,
60    git_watch_changed: bool,
61    git_completion: Option<GitDiffEvent>,
62    filesystem: FakeFilesystem,
63}
64
65impl Default for FakeExecutor {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl FakeExecutor {
72    pub fn new() -> Self {
73        Self::with_git(FakeGit::default())
74    }
75
76    pub fn with_git(git: FakeGit) -> Self {
77        Self {
78            available: VecDeque::new(),
79            pending: VecDeque::new(),
80            git,
81            git_watch: None,
82            git_watch_started: false,
83            git_scope: DiffScope::default(),
84            git_watch_changed: false,
85            git_completion: None,
86            filesystem: FakeFilesystem::default(),
87        }
88    }
89
90    pub fn git(&self) -> &FakeGit {
91        &self.git
92    }
93
94    pub fn git_mut(&mut self) -> &mut FakeGit {
95        &mut self.git
96    }
97
98    pub fn git_watch_id(&self) -> Option<RequestId> {
99        self.git_watch.as_ref().map(|event| event.review_id)
100    }
101
102    pub fn next_git_watch_event(&mut self) -> Option<GitWatchEvent> {
103        if !self.git_watch_started {
104            return None;
105        }
106        let current = self.git_watch.as_mut()?;
107        let result = self.git.watch_snapshot(self.git_scope, current.result.as_ref().ok());
108        let unchanged = match (&current.result, &result) {
109            (Ok(previous), Ok(next)) => {
110                previous.snapshot == next.snapshot && previous.error_message() == next.error_message()
111            }
112            (Err(previous), Err(next)) => previous.to_string() == next.to_string(),
113            _ => false,
114        };
115        if unchanged && !self.git_watch_changed {
116            return None;
117        }
118        self.git_watch_changed = false;
119        current.result = result;
120        Some(current.clone())
121    }
122
123    pub fn filesystem(&self) -> &FakeFilesystem {
124        &self.filesystem
125    }
126
127    pub fn filesystem_mut(&mut self) -> &mut FakeFilesystem {
128        &mut self.filesystem
129    }
130
131    pub fn record(&mut self, commands: impl IntoIterator<Item = Command>) {
132        for command in commands {
133            self.available.push_back(command.clone());
134            self.pending.push_back(command);
135        }
136    }
137
138    fn complete(&mut self, command: Command) -> Option<CommandResult> {
139        match command {
140            Command::ResolveWorkspace { cwd } => Some(CommandResult::WorkspaceResolved {
141                status: WorkspaceStatus::new(cwd.display().to_string(), None),
142                cwd,
143            }),
144            Command::Git(GitCommand::Apply { review_id, action }) => {
145                if self.git_watch_id() != Some(review_id) || !self.git_watch_started {
146                    return Some(CommandResult::GitWatch(GitWatchEvent {
147                        review_id,
148                        result: Err(Arc::new(GitWatchError::Stopped)),
149                    }));
150                }
151                Some(CommandResult::GitDiff(GitDiffEvent {
152                    review_id,
153                    result: self.git.apply(action).map_err(Arc::new),
154                }))
155            }
156            Command::GitWatch(command) => self.watch_git(&command),
157            Command::Filesystem(FilesystemCommand::PrepareSubmission { attachments }) => {
158                Some(CommandResult::SubmissionPrepared(self.filesystem.build_attachments(&attachments)))
159            }
160            Command::Filesystem(FilesystemCommand::IndexFiles { request_id, root }) => {
161                Some(CommandResult::FilesIndexed { request_id, files: self.filesystem.index_files(&root) })
162            }
163            _ => None,
164        }
165    }
166
167    fn watch_git(&mut self, command: &GitWatchCommand) -> Option<CommandResult> {
168        let (review_id, scope) = match *command {
169            GitWatchCommand::Open { review_id, scope, .. } => {
170                self.git_watch_started = false;
171                self.git_watch_changed = false;
172                self.git_completion = None;
173                self.git_watch = None;
174                (review_id, scope)
175            }
176            GitWatchCommand::Refresh { review_id, scope } => {
177                if self.git_watch_id() != Some(review_id) {
178                    return Some(CommandResult::GitWatch(GitWatchEvent {
179                        review_id,
180                        result: Err(Arc::new(GitWatchError::Stopped)),
181                    }));
182                }
183                self.git_scope = scope;
184                if self.git_watch_started {
185                    self.git_watch_changed = self.next_git_watch_event().is_some();
186                    let state = self.git_watch.as_ref().expect("active watch").result.as_ref().expect("started watch");
187                    let result = state.error.clone().map_or(Ok(()), Err);
188                    return Some(CommandResult::GitDiff(GitDiffEvent { review_id, result }));
189                }
190                (review_id, scope)
191            }
192            GitWatchCommand::Close { review_id } => {
193                if self.git_watch_id() == Some(review_id) {
194                    self.git_watch = None;
195                    self.git_watch_started = false;
196                    self.git_watch_changed = false;
197                    self.git_completion = None;
198                }
199                return None;
200            }
201        };
202        self.git_scope = scope;
203        let event = GitWatchEvent { review_id, result: self.git.watch_snapshot(scope, None) };
204        self.git_watch_started = event.result.is_ok();
205        if self.git_watch_started {
206            self.git_completion = Some(GitDiffEvent { review_id, result: Ok(()) });
207        }
208        self.git_watch = Some(event.clone());
209        Some(CommandResult::GitWatch(event))
210    }
211
212    fn take_pending(&mut self) -> Vec<Command> {
213        self.pending.drain(..).collect()
214    }
215
216    fn clear_available(&mut self) {
217        self.available.clear();
218    }
219
220    pub fn take_commands(&mut self) -> Vec<Command> {
221        self.pending.clear();
222        self.available.drain(..).collect()
223    }
224}
225
226/// An in-memory filesystem used by command-oriented tests.
227#[derive(Clone, Default)]
228pub struct FakeFilesystem {
229    files: BTreeMap<PathBuf, Vec<u8>>,
230    directories: BTreeSet<PathBuf>,
231    settings: Option<UiSettings>,
232}
233
234impl FakeFilesystem {
235    pub fn new() -> Self {
236        Self::default()
237    }
238
239    pub fn create_dir(&mut self, path: impl Into<PathBuf>) {
240        self.directories.insert(path.into());
241    }
242
243    pub fn write_file(&mut self, path: impl Into<PathBuf>, contents: impl AsRef<[u8]>) {
244        let path = path.into();
245        if let Some(parent) = path.parent() {
246            self.directories.insert(parent.to_path_buf());
247        }
248        self.files.insert(path, contents.as_ref().to_vec());
249    }
250
251    pub fn remove_file(&mut self, path: &Path) -> bool {
252        self.files.remove(path).is_some()
253    }
254
255    pub fn read_file(&self, path: &Path) -> Option<&[u8]> {
256        self.files.get(path).map(Vec::as_slice)
257    }
258
259    pub fn read_to_string(&self, path: &Path) -> Option<String> {
260        self.read_file(path).and_then(|contents| String::from_utf8(contents.to_vec()).ok())
261    }
262
263    pub fn contains(&self, path: &Path) -> bool {
264        self.files.contains_key(path) || self.directories.contains(path)
265    }
266
267    pub fn files(&self) -> impl Iterator<Item = (&Path, &[u8])> {
268        self.files.iter().map(|(path, contents)| (path.as_path(), contents.as_slice()))
269    }
270
271    pub fn directories(&self) -> impl Iterator<Item = &Path> {
272        self.directories.iter().map(PathBuf::as_path)
273    }
274
275    pub fn save_settings(&mut self, settings: UiSettings) {
276        self.settings = Some(settings);
277    }
278
279    pub fn settings(&self) -> Option<&UiSettings> {
280        self.settings.as_ref()
281    }
282
283    /// The runtime's file index over the in-memory tree, minus gitignore
284    /// semantics, which need a real repository.
285    pub fn index_files(&self, root: &Path) -> Vec<FileEntry> {
286        let paths = self.files.keys().filter(|path| path.starts_with(root)).cloned();
287        file_entries(root, paths, MAX_INDEXED_FILES)
288    }
289
290    /// The runtime's attachment preparation over in-memory contents: the same
291    /// pure encoding as the real reader, with reads answered from this tree.
292    pub fn build_attachments(&self, attachments: &[PromptAttachment]) -> AttachmentOutcome {
293        build_attachments_with(attachments, |path, display_name| {
294            if self.directories.contains(path) {
295                return Err(format!("Failed to read {display_name}: is a directory"));
296            }
297            self.files.get(path).cloned().ok_or_else(|| format!("Failed to read {display_name}: file not found"))
298        })
299    }
300}
301
302/// A small stateful Git model. It keeps working-tree, index, and committed
303/// snapshots separate so staging and discarding have observable semantics.
304#[derive(Clone, Default)]
305pub struct FakeGit {
306    state: std::sync::Arc<std::sync::Mutex<FakeGitState>>,
307}
308
309#[derive(Default)]
310struct FakeGitState {
311    root: PathBuf,
312    files: BTreeMap<String, FakeGitFile>,
313    commits: Vec<String>,
314    is_repo: bool,
315}
316
317#[derive(Clone, Debug, PartialEq, Eq)]
318pub struct FakeGitFile {
319    pub path: String,
320    pub contents: Option<Vec<u8>>,
321    pub staged_contents: Option<Vec<u8>>,
322    pub committed_contents: Option<Vec<u8>>,
323}
324
325impl FakeGit {
326    pub fn new(root: impl Into<PathBuf>) -> Self {
327        let state = FakeGitState { root: root.into(), is_repo: true, ..FakeGitState::default() };
328        Self { state: std::sync::Arc::new(std::sync::Mutex::new(state)) }
329    }
330
331    pub fn not_a_repository(root: impl Into<PathBuf>) -> Self {
332        let state = FakeGitState { root: root.into(), ..FakeGitState::default() };
333        Self { state: std::sync::Arc::new(std::sync::Mutex::new(state)) }
334    }
335
336    pub fn set_repository_available(&mut self, available: bool) {
337        self.state.lock().unwrap().is_repo = available;
338    }
339
340    pub fn root(&self) -> PathBuf {
341        self.state.lock().unwrap().root.clone()
342    }
343
344    pub fn add_file(&mut self, path: impl Into<String>, contents: impl AsRef<[u8]>) {
345        let path = path.into();
346        self.state.lock().unwrap().files.insert(
347            path.clone(),
348            FakeGitFile {
349                path,
350                contents: Some(contents.as_ref().to_vec()),
351                staged_contents: None,
352                committed_contents: None,
353            },
354        );
355    }
356
357    pub fn write_file(&mut self, path: impl Into<String>, contents: impl AsRef<[u8]>) {
358        let path = path.into();
359        let mut state = self.state.lock().unwrap();
360        let file = state.files.entry(path.clone()).or_insert_with(|| FakeGitFile {
361            path,
362            contents: None,
363            staged_contents: None,
364            committed_contents: None,
365        });
366        file.contents = Some(contents.as_ref().to_vec());
367    }
368
369    pub fn remove_file(&mut self, path: &str) {
370        if let Some(file) = self.state.lock().unwrap().files.get_mut(path) {
371            file.contents = None;
372        }
373    }
374
375    pub fn stage(&mut self, path: &str) -> bool {
376        let mut state = self.state.lock().unwrap();
377        let Some(file) = state.files.get_mut(path) else { return false };
378        file.staged_contents = file.contents.clone();
379        true
380    }
381
382    pub fn unstage(&mut self, path: &str) -> bool {
383        let mut state = self.state.lock().unwrap();
384        let Some(file) = state.files.get_mut(path) else { return false };
385        file.staged_contents = file.committed_contents.clone();
386        true
387    }
388
389    pub fn stage_all(&mut self) {
390        let mut state = self.state.lock().unwrap();
391        for file in state.files.values_mut() {
392            file.staged_contents = file.contents.clone();
393        }
394    }
395
396    pub fn unstage_all(&mut self) {
397        let mut state = self.state.lock().unwrap();
398        for file in state.files.values_mut() {
399            file.staged_contents = file.committed_contents.clone();
400        }
401    }
402
403    pub fn discard(&mut self, path: &str) -> bool {
404        let mut state = self.state.lock().unwrap();
405        let Some(file) = state.files.get_mut(path) else { return false };
406        file.contents = file.committed_contents.clone();
407        file.staged_contents = file.committed_contents.clone();
408        true
409    }
410
411    pub fn commit(&mut self, message: impl Into<String>) -> Result<(), GitDiffError> {
412        let message = message.into();
413        let mut state = self.state.lock().unwrap();
414        if message.trim().is_empty() {
415            return Err(GitDiffError::EmptyCommitMessage);
416        }
417        if !state.files.values().any(|file| file.staged_contents != file.committed_contents) {
418            return Err(GitDiffError::CommandFailed { operation: "commit", status: Some(1), stderr: String::new() });
419        }
420        for file in state.files.values_mut() {
421            if file.staged_contents != file.committed_contents {
422                file.committed_contents = file.staged_contents.clone();
423            }
424        }
425        state.commits.push(message);
426        Ok(())
427    }
428
429    pub fn file(&self, path: &str) -> Option<FakeGitFile> {
430        self.state.lock().unwrap().files.get(path).cloned()
431    }
432
433    pub fn files(&self) -> Vec<FakeGitFile> {
434        self.state.lock().unwrap().files.values().cloned().collect()
435    }
436
437    pub fn commits(&self) -> Vec<String> {
438        self.state.lock().unwrap().commits.clone()
439    }
440
441    pub fn status(&self, path: &str) -> Option<(FileStatus, StageState)> {
442        self.state.lock().unwrap().files.get(path).and_then(status_of)
443    }
444
445    pub fn apply(&mut self, action: clankerdiff_ratatui::diff::RepositoryAction) -> Result<(), GitDiffError> {
446        use clankerdiff_ratatui::diff::RepositoryAction;
447        if !self.state.lock().unwrap().is_repo {
448            return Err(GitDiffError::NotRepository);
449        }
450        match action {
451            RepositoryAction::StagePaths(paths) => {
452                for path in paths {
453                    self.stage(path.as_str());
454                }
455                Ok(())
456            }
457            RepositoryAction::UnstagePaths(paths) => {
458                for path in paths {
459                    self.unstage(path.as_str());
460                }
461                Ok(())
462            }
463            RepositoryAction::StageAll => {
464                self.stage_all();
465                Ok(())
466            }
467            RepositoryAction::UnstageAll => {
468                self.unstage_all();
469                Ok(())
470            }
471            RepositoryAction::Commit { message } => self.commit(message),
472            RepositoryAction::Discard { path, status } => {
473                if status == FileStatus::Untracked {
474                    self.state.lock().unwrap().files.remove(path.as_str());
475                } else {
476                    self.discard(path.as_str());
477                }
478                Ok(())
479            }
480        }
481    }
482
483    fn watch_snapshot(&self, scope: DiffScope, previous: Option<&RepositoryState>) -> GitWatchResult {
484        match self.load_diff(scope) {
485            Ok(snapshot) => Ok(RepositoryState { snapshot: Arc::new(snapshot), error: None }),
486            Err(error) => match previous {
487                Some(previous) => {
488                    Ok(RepositoryState { snapshot: previous.snapshot.clone(), error: Some(Arc::new(error)) })
489                }
490                None => Err(Arc::new(GitWatchError::Git(error))),
491            },
492        }
493    }
494
495    fn load_diff(&self, scope: DiffScope) -> Result<RepositorySnapshot, GitDiffError> {
496        let state = self.state.lock().unwrap();
497        if !state.is_repo {
498            return Err(GitDiffError::NotRepository);
499        }
500        let mut files = Vec::new();
501        for file in state.files.values() {
502            let (old, new) = match scope {
503                DiffScope::Staged => (&file.committed_contents, &file.staged_contents),
504                DiffScope::Unstaged => (&file.staged_contents, &file.contents),
505                DiffScope::Both => (&file.committed_contents, &file.contents),
506            };
507            if old == new {
508                continue;
509            }
510            let binary = old.as_ref().is_some_and(|bytes| is_binary(bytes))
511                || new.as_ref().is_some_and(|bytes| is_binary(bytes));
512            let mut diff = if binary {
513                FileDiff::from_texts(file.path.clone(), "", "")?
514            } else {
515                let old_text = old.as_deref().map(String::from_utf8_lossy).unwrap_or_default();
516                let new_text = new.as_deref().map(String::from_utf8_lossy).unwrap_or_default();
517                FileDiff::from_texts(file.path.clone(), &old_text, &new_text)?
518            };
519            diff.status = match (old, new) {
520                (None, Some(_)) if file.committed_contents.is_none() && file.staged_contents.is_none() => {
521                    FileStatus::Untracked
522                }
523                (None, Some(_)) => FileStatus::Added,
524                (Some(_), None) => FileStatus::Deleted,
525                _ => FileStatus::Modified,
526            };
527            diff.old_path = old.is_some().then(|| diff.path.clone());
528            diff.staged = status_of(file).map_or(StageState::Unstaged, |(_, stage)| stage);
529            diff.binary = binary;
530            diff = diff.with_sources(fake_source(old.as_deref()), fake_source(new.as_deref()));
531            files.push(diff);
532        }
533        let document = DiffDocument { repo_root: state.root.to_string_lossy().into_owned(), files };
534        Ok(clankerdiff_git::RepositorySnapshot { scope, document: std::sync::Arc::new(document) })
535    }
536}
537
538fn status_of(file: &FakeGitFile) -> Option<(FileStatus, StageState)> {
539    let staged_changed = file.staged_contents != file.committed_contents;
540    let working_changed = file.contents != file.staged_contents;
541    if !staged_changed && !working_changed {
542        return None;
543    }
544
545    if file.committed_contents.is_none() {
546        let stage = match (file.staged_contents.is_some(), working_changed) {
547            (true, true) => StageState::PartiallyStaged,
548            (true, false) => StageState::Staged,
549            (false, _) => StageState::Unstaged,
550        };
551        return Some((FileStatus::Untracked, stage));
552    }
553
554    let stage = match (staged_changed, working_changed) {
555        (true, true) => StageState::PartiallyStaged,
556        (true, false) => StageState::Staged,
557        (false, true) => StageState::Unstaged,
558        (false, false) => unreachable!("clean files returned above"),
559    };
560    let status = if file.contents.is_none() { FileStatus::Deleted } else { FileStatus::Modified };
561    Some((status, stage))
562}
563
564fn fake_source(bytes: Option<&[u8]>) -> clankerdiff_ratatui::diff::SourceResult {
565    use clankerdiff_ratatui::diff::{SourceDocument, SourceUnavailable};
566    match bytes {
567        None => Err(SourceUnavailable::Absent),
568        Some(bytes) if is_binary(bytes) => Err(SourceUnavailable::Binary),
569        Some(bytes) => SourceDocument::new(String::from_utf8_lossy(bytes)).map(std::sync::Arc::new),
570    }
571}
572
573fn is_binary(bytes: &[u8]) -> bool {
574    bytes.iter().take(8192).any(|byte| *byte == 0) || std::str::from_utf8(bytes).is_err()
575}
576
577/// Deterministic terminal wrapper used by focused golden tests.
578pub struct TestTerminal {
579    terminal: Terminal<TestBackend>,
580}
581
582impl TestTerminal {
583    pub fn new(width: u16, height: u16) -> Self {
584        Self { terminal: test_terminal(TestBackend::new(width, height)) }
585    }
586
587    pub fn terminal(&mut self) -> &mut Terminal<TestBackend> {
588        &mut self.terminal
589    }
590
591    pub fn resize(&mut self, width: u16, height: u16) {
592        self.terminal.backend_mut().resize(width, height);
593    }
594
595    pub fn viewport(&mut self) -> Buffer {
596        viewport_buffer(&mut self.terminal)
597    }
598
599    pub fn history(&mut self) -> Buffer {
600        history_buffer(&mut self.terminal)
601    }
602
603    pub fn conversation(&mut self) -> Buffer {
604        conversation_buffer(&mut self.terminal)
605    }
606}
607
608/// A backend that records terminal history operations without involving a real
609/// terminal. It is intentionally small: only operations with an observable
610/// presentation contract are recorded.
611#[derive(Debug, Clone, Copy, PartialEq, Eq)]
612pub enum BackendEvent {
613    ShowCursor,
614    Scroll,
615}
616
617#[derive(Debug)]
618pub struct RecordingBackend {
619    inner: TestBackend,
620    events: Vec<BackendEvent>,
621}
622
623impl RecordingBackend {
624    pub fn new(width: u16, height: u16) -> Self {
625        Self { inner: TestBackend::new(width, height), events: Vec::new() }
626    }
627
628    pub fn events(&self) -> &[BackendEvent] {
629        &self.events
630    }
631
632    pub fn clear_events(&mut self) {
633        self.events.clear();
634    }
635
636    pub fn resize(&mut self, width: u16, height: u16) {
637        self.inner.resize(width, height);
638    }
639
640    pub fn buffer(&self) -> &Buffer {
641        self.inner.buffer()
642    }
643
644    pub fn scrollback(&self) -> &Buffer {
645        self.inner.scrollback()
646    }
647}
648
649impl Backend for RecordingBackend {
650    type Error = std::convert::Infallible;
651
652    fn draw<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
653    where
654        I: Iterator<Item = (u16, u16, &'a Cell)>,
655    {
656        self.inner.draw(content)
657    }
658
659    fn append_lines(&mut self, lines: u16) -> Result<(), Self::Error> {
660        self.inner.append_lines(lines)
661    }
662
663    fn hide_cursor(&mut self) -> Result<(), Self::Error> {
664        self.inner.hide_cursor()
665    }
666
667    fn show_cursor(&mut self) -> Result<(), Self::Error> {
668        self.events.push(BackendEvent::ShowCursor);
669        self.inner.show_cursor()
670    }
671
672    fn get_cursor_position(&mut self) -> Result<Position, Self::Error> {
673        self.inner.get_cursor_position()
674    }
675
676    fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> Result<(), Self::Error> {
677        self.inner.set_cursor_position(position)
678    }
679
680    fn clear(&mut self) -> Result<(), Self::Error> {
681        self.inner.clear()
682    }
683
684    fn clear_region(&mut self, clear_type: ClearType) -> Result<(), Self::Error> {
685        self.inner.clear_region(clear_type)
686    }
687
688    fn size(&self) -> Result<Size, Self::Error> {
689        self.inner.size()
690    }
691
692    fn window_size(&mut self) -> Result<WindowSize, Self::Error> {
693        self.inner.window_size()
694    }
695
696    fn flush(&mut self) -> Result<(), Self::Error> {
697        self.inner.flush()
698    }
699
700    fn scroll_region_up(&mut self, region: std::ops::Range<u16>, lines: u16) -> Result<(), Self::Error> {
701        self.events.push(BackendEvent::Scroll);
702        let region = if region == (0..1) { 0..self.inner.size().unwrap().height } else { region };
703        self.inner.scroll_region_up(region, lines)
704    }
705
706    fn scroll_region_down(&mut self, region: std::ops::Range<u16>, lines: u16) -> Result<(), Self::Error> {
707        self.events.push(BackendEvent::Scroll);
708        self.inner.scroll_region_down(region, lines)
709    }
710}
711
712/// Backend work since the last [`CountingBackend::take_stats`]: how much a
713/// frame pushed at the terminal. Because ratatui diffs buffers before flushing,
714/// `cells_drawn` counts only cells that actually changed — a frame that
715/// repaints a settled screen costs nothing.
716#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
717pub struct BackendStats {
718    pub draws: u64,
719    pub cells_drawn: u64,
720    pub scrolls: u64,
721}
722
723/// A [`TestBackend`] that counts what rendering pushes at it, for tests that
724/// bound rendering work instead of timing it.
725#[derive(Debug)]
726pub struct CountingBackend {
727    inner: TestBackend,
728    stats: BackendStats,
729}
730
731impl CountingBackend {
732    pub fn new(width: u16, height: u16) -> Self {
733        Self { inner: TestBackend::new(width, height), stats: BackendStats::default() }
734    }
735
736    pub fn take_stats(&mut self) -> BackendStats {
737        std::mem::take(&mut self.stats)
738    }
739
740    pub fn buffer(&self) -> &Buffer {
741        self.inner.buffer()
742    }
743
744    pub fn scrollback(&self) -> &Buffer {
745        self.inner.scrollback()
746    }
747}
748
749impl Backend for CountingBackend {
750    type Error = std::convert::Infallible;
751
752    fn draw<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
753    where
754        I: Iterator<Item = (u16, u16, &'a Cell)>,
755    {
756        self.stats.draws += 1;
757        let mut cells = 0u64;
758        let drawn = self.inner.draw(content.inspect(|_| cells += 1));
759        self.stats.cells_drawn += cells;
760        drawn
761    }
762
763    fn hide_cursor(&mut self) -> Result<(), Self::Error> {
764        self.inner.hide_cursor()
765    }
766
767    fn show_cursor(&mut self) -> Result<(), Self::Error> {
768        self.inner.show_cursor()
769    }
770
771    fn get_cursor_position(&mut self) -> Result<Position, Self::Error> {
772        self.inner.get_cursor_position()
773    }
774
775    fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> Result<(), Self::Error> {
776        self.inner.set_cursor_position(position)
777    }
778
779    fn clear(&mut self) -> Result<(), Self::Error> {
780        self.inner.clear()
781    }
782
783    fn clear_region(&mut self, clear_type: ClearType) -> Result<(), Self::Error> {
784        self.inner.clear_region(clear_type)
785    }
786
787    fn size(&self) -> Result<Size, Self::Error> {
788        self.inner.size()
789    }
790
791    fn window_size(&mut self) -> Result<WindowSize, Self::Error> {
792        self.inner.window_size()
793    }
794
795    fn flush(&mut self) -> Result<(), Self::Error> {
796        self.inner.flush()
797    }
798
799    fn scroll_region_up(&mut self, region: std::ops::Range<u16>, lines: u16) -> Result<(), Self::Error> {
800        self.stats.scrolls += 1;
801        self.inner.scroll_region_up(region, lines)
802    }
803
804    fn scroll_region_down(&mut self, region: std::ops::Range<u16>, lines: u16) -> Result<(), Self::Error> {
805        self.stats.scrolls += 1;
806        self.inner.scroll_region_down(region, lines)
807    }
808}
809
810/// A whole UI scenario: the app, the renderer that owns its scrollback, the
811/// terminal it draws into, and the receiver for the commands the app sends.
812///
813/// Construct through [`TestUi::new`], [`TestUi::with_dimensions`], or
814/// [`TestUiBuilder`]. Input, ACP events, ticks, and task results are routed the
815/// same way the event loop routes them, and drawing happens against the same
816/// renderer instance every frame so committed scrollback survives.
817pub struct TestUi<B: Backend = TestBackend> {
818    app: App,
819    renderer: Renderer,
820    terminal: Terminal<B>,
821    executor: FakeExecutor,
822    opened_urls: Arc<Mutex<Vec<String>>>,
823}
824
825impl<B: Backend> TestUi<B>
826where
827    B::Error: std::fmt::Debug,
828{
829    /// Builds a UI around an arbitrary backend (e.g. a recording backend that
830    /// asserts on frame-level terminal commands). The app is the default
831    /// scenario; configure anything else through [`TestUiBuilder`].
832    pub fn with_backend(backend: B) -> Self {
833        let builder = TestUiBuilder::new();
834        let app = App::new(builder.app_config());
835        Self {
836            app,
837            renderer: Renderer::new(),
838            terminal: test_terminal(backend),
839            executor: FakeExecutor::new(),
840            opened_urls: builder.opened_urls.clone(),
841        }
842    }
843
844    /// The app under test, for the read-only queries assertions are built from.
845    /// Drive it through this harness rather than mutating it directly, so the
846    /// commands it emits still reach the fake runtime.
847    pub fn app(&self) -> &App {
848        &self.app
849    }
850
851    /// Delivers one message through the public application boundary and records
852    /// every command it emits in the fake runtime.
853    pub fn deliver(&mut self, message: Message) {
854        self.executor.record(self.app.update(message));
855    }
856
857    pub fn deliver_result(&mut self, result: CommandResult) {
858        self.deliver(Message::CommandFinished(Box::new(result)));
859    }
860
861    /// Commands emitted by the public application boundary, in order.
862    pub fn executor(&self) -> &FakeExecutor {
863        &self.executor
864    }
865
866    pub fn executor_mut(&mut self) -> &mut FakeExecutor {
867        &mut self.executor
868    }
869
870    pub fn take_commands(&mut self) -> Vec<Command> {
871        self.executor.take_commands()
872    }
873
874    pub fn next_command(&mut self) -> Option<Command> {
875        self.executor.available.pop_front()
876    }
877
878    pub fn next_agent_command(&mut self) -> Option<AgentCommand> {
879        while let Some(command) = self.next_command() {
880            if let Command::Agent(command) = command {
881                return Some(command);
882            }
883        }
884        None
885    }
886
887    pub fn backend(&self) -> &B {
888        self.terminal.backend()
889    }
890
891    pub fn backend_mut(&mut self) -> &mut B {
892        self.terminal.backend_mut()
893    }
894
895    pub fn viewport_area(&mut self) -> ratatui::layout::Rect {
896        self.terminal.get_frame().area()
897    }
898
899    pub fn viewport_height(&mut self) -> u16 {
900        self.viewport_area().height
901    }
902
903    /// Measures the composer at `width` with the active theme.
904    pub fn composer_layout(&mut self, width: u16) -> ComposerLayout {
905        let theme = self.app.theme().clone();
906        let composer = self.app.composer_mut();
907        composer.on_resize(width);
908        composer.layout(width, &theme)
909    }
910
911    /// Draws one frame, like the event loop does after every input batch.
912    pub fn draw(&mut self) {
913        self.try_draw().unwrap();
914    }
915
916    pub fn try_draw(&mut self) -> Result<(), crate::error::RenderError<B::Error>> {
917        self.renderer.draw(&mut self.terminal, &mut self.app)
918    }
919
920    pub fn render_stats(&mut self) -> RenderStats {
921        self.renderer.take_stats()
922    }
923
924    /// URLs elicitation prompts asked to open, in order. The harness records
925    /// them instead of spawning a browser, so no test touches the host.
926    pub fn opened_urls(&self) -> Vec<String> {
927        self.opened_urls.lock().unwrap().clone()
928    }
929
930    /// Seeds a deterministic long conversation: `turns` completed turns of
931    /// user messages, thoughts, markdown prose with fenced code, bash and edit
932    /// tool calls with diffs, and a sub-agent tree every eighth turn — drawing
933    /// after each turn so scrollback commits exactly like a live session.
934    pub fn seed_long_history(&mut self, turns: usize) {
935        for turn in 0..turns {
936            let prompt = format!("Turn {turn}: reconcile the writer path in module_{turn} and add a regression test.");
937            self.submit(&prompt);
938            self.acp_event(session_update(acp::SessionUpdate::UserMessage(
939                acp::UserMessage::new(format!("seed-user-{turn}"))
940                    .content(vec![acp::ContentBlock::Text(acp::TextContent::new(prompt))]),
941            )));
942            self.acp_event(session_update(acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new(
943                acp::ContentBlock::Text(acp::TextContent::new(format!(
944                    "Reading module_{turn} to find the torn-update window before touching any call site."
945                ))),
946                format!("seed-thought-{turn}"),
947            ))));
948            self.acp_event(text_chunk_with_id(&format!("seed-response-{turn}"), SEED_PROSE));
949            self.acp_event(text_chunk_with_id(&format!("seed-response-{turn}"), SEED_CODE_BLOCK));
950            let bash = format!("seed-bash-{turn}");
951            self.acp_event(seed_bash_tool(&bash));
952            self.acp_event(tool_completed(&bash));
953            let edit = format!("seed-edit-{turn}");
954            self.acp_event(seed_edit_tool(&edit, turn));
955            self.acp_event(seed_tool_diff(&edit, turn));
956            if turn % 8 == 0 {
957                self.seed_sub_agent_tree(turn);
958            }
959            self.acp_event(text_chunk_with_id(&format!("seed-closing-{turn}"), SEED_CLOSING));
960            self.complete_prompt(acp::StopReason::EndTurn);
961            self.draw();
962        }
963    }
964
965    /// One spawn tool whose sub-agents run and finish, leaving a sealed tree.
966    fn seed_sub_agent_tree(&mut self, turn: usize) {
967        let parent = format!("seed-spawn-{turn}");
968        self.acp_event(seed_spawn_tool(&parent));
969        self.acp_event(tool_completed(&parent));
970        for agent in ["explorer", "fixer"] {
971            let task = format!("{parent}-{agent}");
972            self.acp_event(seed_sub_agent(
973                &parent,
974                &task,
975                agent,
976                SubAgentEvent::ToolCall {
977                    request: SubAgentToolRequest {
978                        id: format!("{task}-grep"),
979                        name: "grep".to_string(),
980                        arguments: r#"{"pattern":"torn update"}"#.to_string(),
981                    },
982                },
983            ));
984            self.acp_event(seed_sub_agent(
985                &parent,
986                &task,
987                agent,
988                SubAgentEvent::ToolResult {
989                    result: SubAgentToolResult {
990                        id: format!("{task}-grep"),
991                        name: "grep".to_string(),
992                        result_meta: None,
993                    },
994                },
995            ));
996            self.acp_event(seed_sub_agent(&parent, &task, agent, SubAgentEvent::Done));
997        }
998    }
999
1000    /// Streams one assistant message of `total_bytes` in `chunk_bytes` chunks,
1001    /// drawing after every chunk the way the event loop draws after every
1002    /// wakeup. The message stays one open item while it streams.
1003    pub fn stream_message(&mut self, content: StreamContent, total_bytes: usize, chunk_bytes: usize) {
1004        let thought = matches!(content, StreamContent::Thought);
1005        let message = match content {
1006            StreamContent::Prose => prose_message(total_bytes),
1007            StreamContent::CodeBlock => code_block_message(total_bytes),
1008            StreamContent::Thought => thought_message(total_bytes),
1009        };
1010        for chunk in chunk_message(&message, chunk_bytes.max(1)) {
1011            if thought {
1012                self.acp_event(thought_chunk(&chunk));
1013            } else {
1014                self.acp_event(text_chunk(&chunk));
1015            }
1016            self.draw();
1017        }
1018    }
1019
1020    /// Finishes the in-flight turn and advances synthetic time past every
1021    /// grace period, leaving a session that owes the event loop nothing.
1022    pub fn settle(&mut self) {
1023        self.complete_prompt(acp::StopReason::EndTurn);
1024        let mut now = Instant::now();
1025        // The plan tracker's grace period (3s) is the longest deadline a
1026        // settled session can still be waiting on.
1027        for _ in 0..12 {
1028            self.tick(now);
1029            now += Duration::from_millis(500);
1030            if !self.app().wants_tick() {
1031                break;
1032            }
1033        }
1034        assert!(!self.app().wants_tick(), "a settled session must stop driving the tick loop");
1035        self.draw();
1036    }
1037
1038    /// Routes a terminal event (key, paste, mouse, resize) the way the event
1039    /// loop does.
1040    pub fn terminal_event(&mut self, event: Event) {
1041        self.deliver(Message::Terminal(event));
1042    }
1043
1044    pub fn key(&mut self, key: KeyEvent) {
1045        self.deliver(Message::Terminal(Event::Key(key)));
1046    }
1047
1048    pub fn type_text(&mut self, text: &str) {
1049        for character in text.chars() {
1050            self.key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE));
1051        }
1052    }
1053
1054    /// Types `text` and submits it with Enter.
1055    pub fn submit(&mut self, text: &str) {
1056        self.type_text(text);
1057        self.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
1058    }
1059
1060    pub fn paste(&mut self, text: &str) {
1061        self.deliver(Message::Terminal(Event::Paste(text.to_string())));
1062    }
1063
1064    pub fn acp_event(&mut self, event: AcpEvent) {
1065        self.deliver(Message::Agent(Box::new(event)));
1066    }
1067
1068    pub fn begin_resume(&mut self, session_id: &str, cwd: &str) {
1069        self.deliver_result(CommandResult::SessionsListed(Ok(acp::ListSessionsResponse::new(vec![
1070            acp::SessionInfo::new(session_id.to_string(), cwd),
1071        ]))));
1072        self.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
1073    }
1074
1075    pub fn complete_prompt(&mut self, stop_reason: acp::StopReason) {
1076        self.deliver_result(CommandResult::Prompt(Ok(acp::PromptResponse::new())));
1077        let session_id = self.app.session_id().clone();
1078        let update = acp::SessionUpdate::StateUpdate(acp::StateUpdate::Idle(
1079            acp::IdleStateUpdate::new().stop_reason(stop_reason),
1080        ));
1081        self.acp_event(acp::UpdateSessionNotification::new(session_id, update).into());
1082    }
1083
1084    pub fn tick(&mut self, now: Instant) {
1085        self.deliver(Message::Tick(now));
1086    }
1087
1088    /// Completes the commands configured on the fake executor without touching
1089    /// the real filesystem, Git repository, terminal, or ACP connection.
1090    pub fn settle_tasks(&mut self) {
1091        self.executor.clear_available();
1092        let mut initial_batch = true;
1093        loop {
1094            let pending = self.executor.take_pending();
1095            if pending.is_empty() {
1096                if let Some(event) = self.executor.next_git_watch_event() {
1097                    self.deliver_result(CommandResult::GitWatch(event));
1098                    continue;
1099                }
1100                if let Some(event) = self.executor.git_completion.take() {
1101                    self.deliver_result(CommandResult::GitDiff(event));
1102                    continue;
1103                }
1104                return;
1105            }
1106            if initial_batch {
1107                for command in &pending {
1108                    if matches!(command, Command::Agent(_)) {
1109                        self.executor.available.push_back(command.clone());
1110                    }
1111                }
1112            }
1113            for command in pending {
1114                if let Some(result) = self.executor.complete(command) {
1115                    self.deliver(Message::CommandFinished(Box::new(result)));
1116                }
1117            }
1118            initial_batch = false;
1119        }
1120    }
1121}
1122
1123impl TestUi<TestBackend> {
1124    /// The default scenario: 40x15 terminal, plain app, recording prompt handle.
1125    pub fn new() -> Self {
1126        Self::with_dimensions(40, 15)
1127    }
1128
1129    pub fn with_dimensions(width: u16, height: u16) -> Self {
1130        TestUiBuilder::new().dimensions(width, height).build()
1131    }
1132
1133    /// Resizes the backing terminal. The next [`Self::draw`] re-measures the
1134    /// inline viewport the way `Renderer::draw` does in the event loop.
1135    pub fn resize(&mut self, width: u16, height: u16) {
1136        self.terminal.backend_mut().resize(width, height);
1137    }
1138}
1139
1140impl Default for TestUi<TestBackend> {
1141    fn default() -> Self {
1142        Self::new()
1143    }
1144}
1145
1146/// A backend whose screen and scrollback buffers can be read back, so a
1147/// [`TestUi`] built on it can expose viewport/history/conversation text.
1148pub trait BuffersReader {
1149    fn screen(&self) -> &Buffer;
1150    fn scrollback(&self) -> &Buffer;
1151}
1152
1153impl BuffersReader for TestBackend {
1154    fn screen(&self) -> &Buffer {
1155        self.buffer()
1156    }
1157
1158    fn scrollback(&self) -> &Buffer {
1159        self.scrollback()
1160    }
1161}
1162
1163impl BuffersReader for CountingBackend {
1164    fn screen(&self) -> &Buffer {
1165        self.buffer()
1166    }
1167
1168    fn scrollback(&self) -> &Buffer {
1169        self.scrollback()
1170    }
1171}
1172
1173impl BuffersReader for RecordingBackend {
1174    fn screen(&self) -> &Buffer {
1175        self.buffer()
1176    }
1177
1178    fn scrollback(&self) -> &Buffer {
1179        self.scrollback()
1180    }
1181}
1182
1183impl<B> TestUi<B>
1184where
1185    B: Backend + BuffersReader,
1186    B::Error: std::fmt::Debug,
1187{
1188    /// What the inline viewport currently shows: the composer, status line,
1189    /// and the live tail of the conversation. Draws a frame first, so asserting
1190    /// right after an input never reads a stale screen.
1191    pub fn viewport(&mut self) -> Buffer {
1192        self.draw();
1193        viewport_buffer(&mut self.terminal)
1194    }
1195
1196    /// The terminal's own native scrollback containing committed conversation
1197    /// rows, after drawing a frame.
1198    pub fn history(&mut self) -> Buffer {
1199        self.draw();
1200        history_buffer(&mut self.terminal)
1201    }
1202
1203    /// [`Self::history`] stacked on [`Self::viewport`]: everything the
1204    /// conversation has shown, oldest at the top, after drawing a frame.
1205    pub fn conversation(&mut self) -> Buffer {
1206        self.draw();
1207        conversation_buffer(&mut self.terminal)
1208    }
1209
1210    pub fn viewport_text(&mut self) -> String {
1211        buffer_text(&self.viewport())
1212    }
1213
1214    pub fn history_text(&mut self) -> String {
1215        buffer_text(&self.history())
1216    }
1217
1218    pub fn conversation_text(&mut self) -> String {
1219        buffer_text(&self.conversation())
1220    }
1221
1222    /// Row (within the viewport buffer) of the first line containing `needle`.
1223    pub fn viewport_row(&mut self, needle: &str) -> Option<u16> {
1224        row_containing(&self.viewport(), needle)
1225    }
1226
1227    pub fn assert_viewport_contains(&mut self, needle: &str) {
1228        let viewport = self.viewport_text();
1229        assert!(
1230            viewport.contains(needle),
1231            "viewport should contain {needle:?}:
1232{viewport}"
1233        );
1234    }
1235
1236    pub fn assert_viewport_not_contains(&mut self, needle: &str) {
1237        let viewport = self.viewport_text();
1238        assert!(
1239            !viewport.contains(needle),
1240            "viewport should not contain {needle:?}:
1241{viewport}"
1242        );
1243    }
1244
1245    pub fn assert_history_contains(&mut self, needle: &str) {
1246        let history = self.history_text();
1247        assert!(
1248            history.contains(needle),
1249            "history should contain {needle:?}:
1250{history}"
1251        );
1252    }
1253
1254    pub fn assert_history_not_contains(&mut self, needle: &str) {
1255        let history = self.history_text();
1256        assert!(
1257            !history.contains(needle),
1258            "history should not contain {needle:?}:
1259{history}"
1260        );
1261    }
1262
1263    pub fn assert_conversation_contains(&mut self, needle: &str) {
1264        let conversation = self.conversation_text();
1265        assert!(
1266            conversation.contains(needle),
1267            "conversation should contain {needle:?}:
1268{conversation}"
1269        );
1270    }
1271
1272    pub fn assert_conversation_not_contains(&mut self, needle: &str) {
1273        let conversation = self.conversation_text();
1274        assert!(
1275            !conversation.contains(needle),
1276            "conversation should not contain {needle:?}:
1277{conversation}"
1278        );
1279    }
1280
1281    /// Asserts the viewport's visible text matches `expected` row-by-row.
1282    pub fn assert_viewport<S: AsRef<str>>(&mut self, expected: &[S]) {
1283        assert_buffer_eq(&self.viewport(), expected);
1284    }
1285
1286    /// Asserts the committed history's visible text matches `expected` row-by-row.
1287    pub fn assert_history<S: AsRef<str>>(&mut self, expected: &[S]) {
1288        assert_buffer_eq(&self.history(), expected);
1289    }
1290
1291    /// Asserts the stitched conversation's visible text matches `expected` row-by-row.
1292    pub fn assert_conversation<S: AsRef<str>>(&mut self, expected: &[S]) {
1293        assert_buffer_eq(&self.conversation(), expected);
1294    }
1295}
1296
1297/// Builds a [`TestUi`]: the terminal dimensions plus every app-scenario option
1298/// a test cares about. Defaults match a plain `make_app()`-style scenario.
1299pub struct TestUiBuilder {
1300    width: u16,
1301    height: u16,
1302    working_dir: Option<PathBuf>,
1303    workspace_access: crate::session::WorkspaceAccess,
1304    capabilities: AetherCapabilities,
1305    prompt_capabilities: acp::PromptCapabilities,
1306    config_options: Vec<acp::SessionConfigOption>,
1307    auth_methods: Vec<acp::AuthMethod>,
1308    session_capabilities: Option<acp::SessionCapabilities>,
1309    settings: UiSettings,
1310    workspace_status: Option<WorkspaceStatus>,
1311    git: FakeGit,
1312    opened_urls: Arc<Mutex<Vec<String>>>,
1313}
1314
1315impl Default for TestUiBuilder {
1316    fn default() -> Self {
1317        Self {
1318            width: 40,
1319            height: 15,
1320            working_dir: None,
1321            workspace_access: crate::session::WorkspaceAccess::Local,
1322            capabilities: AetherCapabilities::default(),
1323            prompt_capabilities: acp::PromptCapabilities::new(),
1324            config_options: Vec::new(),
1325            auth_methods: Vec::new(),
1326            session_capabilities: None,
1327            settings: UiSettings::default(),
1328            workspace_status: None,
1329            git: FakeGit::default(),
1330            opened_urls: Arc::new(Mutex::new(Vec::new())),
1331        }
1332    }
1333}
1334
1335impl TestUiBuilder {
1336    pub fn new() -> Self {
1337        Self::default()
1338    }
1339
1340    pub fn dimensions(mut self, width: u16, height: u16) -> Self {
1341        self.width = width;
1342        self.height = height;
1343        self
1344    }
1345
1346    pub fn remote_workspace(mut self) -> Self {
1347        self.workspace_access = crate::session::WorkspaceAccess::Remote;
1348        self
1349    }
1350
1351    pub fn working_dir(mut self, working_dir: impl Into<PathBuf>) -> Self {
1352        self.working_dir = Some(working_dir.into());
1353        self
1354    }
1355
1356    pub fn prompt_capabilities(mut self, capabilities: acp::PromptCapabilities) -> Self {
1357        self.prompt_capabilities = capabilities;
1358        self
1359    }
1360
1361    pub fn config_options(mut self, options: Vec<acp::SessionConfigOption>) -> Self {
1362        self.config_options = options;
1363        self
1364    }
1365
1366    pub fn auth_methods(mut self, methods: Vec<acp::AuthMethod>) -> Self {
1367        self.auth_methods = methods;
1368        self
1369    }
1370
1371    pub fn settings(mut self, settings: UiSettings) -> Self {
1372        self.settings = settings;
1373        self
1374    }
1375
1376    pub fn workspace_status(mut self, workspace_status: WorkspaceStatus) -> Self {
1377        self.workspace_status = Some(workspace_status);
1378        self
1379    }
1380
1381    pub fn git(mut self, git: FakeGit) -> Self {
1382        self.git = git;
1383        self
1384    }
1385
1386    /// Overrides the capabilities wholesale, for tests that care about metadata
1387    /// the individual toggles do not cover.
1388    pub fn session_capabilities(mut self, capabilities: acp::SessionCapabilities) -> Self {
1389        self.session_capabilities = Some(capabilities);
1390        self
1391    }
1392
1393    pub fn prompt_search(mut self) -> Self {
1394        self.capabilities.prompt_search = true;
1395        self
1396    }
1397
1398    pub fn session_preview(mut self) -> Self {
1399        self.capabilities.session_preview = true;
1400        self
1401    }
1402
1403    pub fn workspace_move(mut self) -> Self {
1404        self.capabilities.workspace_move = true;
1405        self
1406    }
1407
1408    /// Builds the whole UI scenario.
1409    pub fn build(self) -> TestUi {
1410        self.finish()
1411    }
1412
1413    pub fn build_from_session(self, session: crate::session::Session) -> (TestUi, UnboundedReceiver<AcpEvent>) {
1414        let (app, events, _) = App::from_session(session, self.settings.clone());
1415        let mut ui = self.finish_with_app(app);
1416        ui.executor.record(ui.app.take_commands());
1417        (ui, events)
1418    }
1419
1420    fn finish(self) -> TestUi {
1421        let app = App::new(self.app_config());
1422        self.finish_with_app(app)
1423    }
1424
1425    fn finish_with_app(self, app: App) -> TestUi {
1426        TestUi {
1427            app,
1428            renderer: Renderer::new(),
1429            terminal: test_terminal(TestBackend::new(self.width, self.height)),
1430            executor: FakeExecutor::with_git(self.git),
1431            opened_urls: self.opened_urls,
1432        }
1433    }
1434
1435    fn app_config(&self) -> AppConfig {
1436        let session_capabilities = self
1437            .session_capabilities
1438            .clone()
1439            .unwrap_or_else(|| acp::SessionCapabilities::new().meta(Some(self.capabilities.clone().to_meta())));
1440        AppConfig {
1441            initialize_response: acp::InitializeResponse::new(
1442                agent_client_protocol::schema::ProtocolVersion::V2,
1443                acp::Implementation::new("aether", "test"),
1444            )
1445            .capabilities(
1446                acp::AgentCapabilities::new().session(session_capabilities.prompt(self.prompt_capabilities.clone())),
1447            )
1448            .auth_methods(self.auth_methods.clone()),
1449            session_response: acp::NewSessionResponse::new("test-session").config_options(self.config_options.clone()),
1450            workspace_status: self
1451                .workspace_status
1452                .clone()
1453                .unwrap_or_else(|| WorkspaceStatus::new("~/code/demo", Some("main".to_string()))),
1454            working_dir: self.working_dir.clone().unwrap_or_else(|| PathBuf::from(".")),
1455            workspace_access: self.workspace_access,
1456            settings: self.settings.clone(),
1457            browser_opener: {
1458                let opened = self.opened_urls.clone();
1459                Arc::new(move |url: &str| {
1460                    opened.lock().unwrap().push(url.to_string());
1461                    Ok(())
1462                }) as BrowserOpener
1463            },
1464            clipboard_writer: Arc::new(|_| Ok(())),
1465        }
1466    }
1467}
1468
1469/// The terminal a scenario draws into: the inline viewport sized from the
1470/// backend, exactly as the real event loop enters it.
1471fn test_terminal<B: Backend>(backend: B) -> Terminal<B>
1472where
1473    B::Error: std::fmt::Debug,
1474{
1475    let height = backend.size().unwrap().height;
1476    Terminal::with_options(backend, TerminalOptions { viewport: Viewport::Inline(inline_viewport_height(height)) })
1477        .unwrap()
1478}
1479
1480/// What the inline viewport shows: `terminal.get_frame().area()` clipped out of
1481/// the backend's full screen buffer.
1482fn viewport_buffer<B>(terminal: &mut Terminal<B>) -> Buffer
1483where
1484    B: Backend + BuffersReader,
1485{
1486    let area = terminal.get_frame().area();
1487    let screen = terminal.backend().screen();
1488    let mut viewport = Buffer::empty(Rect::new(0, 0, area.width, area.height));
1489    for y in 0..area.height {
1490        for x in 0..area.width {
1491            viewport[(x, y)] = screen[(area.x + x, area.y + y)].clone();
1492        }
1493    }
1494    viewport
1495}
1496
1497/// Content Ratatui's `insert_before` committed above the inline viewport.
1498fn history_buffer<B>(terminal: &mut Terminal<B>) -> Buffer
1499where
1500    B: Backend + BuffersReader,
1501{
1502    let viewport_area = terminal.get_frame().area();
1503    let screen = terminal.backend().screen();
1504    let scrollback = terminal.backend().scrollback();
1505    let history_height = scrollback.area.height.saturating_add(viewport_area.top());
1506    let mut history = Buffer::empty(Rect::new(0, 0, screen.area.width, history_height));
1507    for y in 0..scrollback.area.height {
1508        for x in 0..scrollback.area.width {
1509            history[(x, y)] = scrollback[(x, y)].clone();
1510        }
1511    }
1512    for y in 0..viewport_area.top() {
1513        for x in 0..screen.area.width {
1514            history[(x, scrollback.area.height + y)] = screen[(x, y)].clone();
1515        }
1516    }
1517    history
1518}
1519
1520fn conversation_buffer<B>(terminal: &mut Terminal<B>) -> Buffer
1521where
1522    B: Backend + BuffersReader,
1523{
1524    let history = history_buffer(terminal);
1525    let viewport = viewport_buffer(terminal);
1526    let mut conversation =
1527        Buffer::empty(Rect::new(0, 0, viewport.area.width, history.area.height.saturating_add(viewport.area.height)));
1528    for y in 0..history.area.height {
1529        for x in 0..history.area.width {
1530            conversation[(x, y)] = history[(x, y)].clone();
1531        }
1532    }
1533    for y in 0..viewport.area.height {
1534        for x in 0..viewport.area.width {
1535            conversation[(x, history.area.height + y)] = viewport[(x, y)].clone();
1536        }
1537    }
1538    conversation
1539}
1540
1541/// Whether any cell drawn with `symbol` satisfies `predicate`.
1542pub fn has_cell(buffer: &Buffer, symbol: &str, predicate: impl Fn(&Cell) -> bool) -> bool {
1543    for y in buffer.area.top()..buffer.area.bottom() {
1544        for x in buffer.area.left()..buffer.area.right() {
1545            if let Some(cell) = buffer.cell((x, y))
1546                && cell.symbol() == symbol
1547                && predicate(cell)
1548            {
1549                return true;
1550            }
1551        }
1552    }
1553    false
1554}
1555
1556pub fn line_text(line: &ratatui::text::Line<'_>) -> String {
1557    line.spans.iter().map(|span| span.content.as_ref()).collect()
1558}
1559
1560/// How many rows contain at least one cell with `background`.
1561pub fn rows_with_background(buffer: &Buffer, background: ratatui::style::Color) -> usize {
1562    (buffer.area.top()..buffer.area.bottom())
1563        .filter(|&y| {
1564            (buffer.area.left()..buffer.area.right())
1565                .any(|x| buffer.cell((x, y)).is_some_and(|cell| cell.bg == background))
1566        })
1567        .count()
1568}
1569
1570pub fn row_containing(buffer: &Buffer, needle: &str) -> Option<u16> {
1571    (buffer.area.top()..buffer.area.bottom()).find(|&y| {
1572        let row = (buffer.area.left()..buffer.area.right())
1573            .map(|x| buffer.cell((x, y)).map_or(" ", Cell::symbol))
1574            .collect::<String>();
1575        row.contains(needle)
1576    })
1577}
1578
1579pub fn buffer_text(buffer: &Buffer) -> String {
1580    let mut out = String::new();
1581    for y in buffer.area.top()..buffer.area.bottom() {
1582        for x in buffer.area.left()..buffer.area.right() {
1583            out.push_str(buffer.cell((x, y)).map_or(" ", Cell::symbol));
1584        }
1585        out.push('\n');
1586    }
1587    out
1588}
1589
1590/// Asserts `buffer`'s visible text matches `expected` row-by-row after trimming
1591/// trailing spaces, panicking on the first mismatched line with the full buffer
1592/// dumped.
1593pub fn assert_buffer_eq<S: AsRef<str>>(buffer: &Buffer, expected: &[S]) {
1594    let actual_lines: Vec<String> =
1595        (buffer.area.top()..buffer.area.bottom()).map(|y| row_text(buffer, y).trim_end().to_string()).collect();
1596    for index in 0..actual_lines.len().max(expected.len()) {
1597        let actual_line = actual_lines.get(index).map_or("", String::as_str);
1598        let expected_line = expected.get(index).map_or("", AsRef::as_ref).trim_end();
1599        assert_eq!(
1600            actual_line,
1601            expected_line,
1602            "line {index} mismatch:\n  expected: {expected_line:?}\n  actual:   {actual_line:?}\n\nfull buffer:\n{}",
1603            actual_lines.join("\n")
1604        );
1605    }
1606}
1607
1608pub fn row_text(buffer: &Buffer, y: u16) -> String {
1609    (buffer.area.left()..buffer.area.right()).map(|x| buffer.cell((x, y)).map_or(" ", Cell::symbol)).collect()
1610}
1611
1612/// The shape of message [`TestUi::stream_message`] streams.
1613#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1614pub enum StreamContent {
1615    /// Flowing paragraphs, so finalization sealing runs as blank lines land and
1616    /// the open item stays small.
1617    Prose,
1618    /// One fenced Rust code block. No blank line outside the fence can finalize
1619    /// it while it streams, so the open item grows to the whole message — the
1620    /// shape that keeps re-rendering everything received so far.
1621    CodeBlock,
1622    /// A thinking stream: line-per-sentence thought chunks, which drive the
1623    /// progress band's activity rather than appending to the conversation.
1624    Thought,
1625}
1626
1627const SEED_PROSE: &str = "\
1628Examining the request. The module guards its invariants behind a shared handle,
1629so the fix has to land on the writer side rather than at each call site. I will
1630rework the boundary so retries cannot observe a torn update, then cover the
1631regression with a test that fails on the current code.
1632
1633";
1634
1635const SEED_CODE_BLOCK: &str = "\
1636```rust
1637fn reconcile(state: &mut State, incoming: Vec<Delta>) -> Outcome {
1638    let mut applied = Vec::with_capacity(incoming.len());
1639    for delta in incoming {
1640        if !state.accepts(&delta) {
1641            continue;
1642        }
1643        state.apply(&delta);
1644        applied.push(delta);
1645    }
1646    state.commit(applied)
1647}
1648```
1649
1650";
1651
1652const SEED_CLOSING: &str = "\
1653Done — the writer now retries atomically and the regression test covers the
1654torn window.
1655
1656";
1657
1658const SEED_DIFF_BEFORE: &str = "\
1659fn reconcile(state: &mut State, incoming: Vec<Delta>) -> Outcome {
1660    let mut applied = Vec::new();
1661    for delta in incoming {
1662        state.apply(&delta);
1663    }
1664    state.commit(Vec::new())
1665}
1666";
1667
1668const SEED_DIFF_AFTER: &str = "\
1669fn reconcile(state: &mut State, incoming: Vec<Delta>) -> Outcome {
1670    let mut applied = Vec::with_capacity(incoming.len());
1671    for delta in incoming {
1672        state.apply(&delta);
1673        applied.push(delta);
1674    }
1675    state.commit(applied)
1676}
1677";
1678
1679pub fn session_update(update: acp::SessionUpdate) -> AcpEvent {
1680    acp::UpdateSessionNotification::new(SessionId::new("test-session"), update).into()
1681}
1682
1683pub fn compaction_update(id: &str, status: acp::CompactionStatus) -> AcpEvent {
1684    session_update(acp::SessionUpdate::CompactionUpdate(acp::CompactionUpdate::new(id, status)))
1685}
1686
1687pub fn text_chunk(text: &str) -> AcpEvent {
1688    text_chunk_with_id("assistant", text)
1689}
1690
1691pub fn text_chunk_with_id(message_id: &str, text: &str) -> AcpEvent {
1692    session_update(acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
1693        acp::ContentBlock::Text(acp::TextContent::new(text)),
1694        message_id.to_string(),
1695    )))
1696}
1697
1698pub fn thought_chunk(text: &str) -> AcpEvent {
1699    session_update(acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new(
1700        acp::ContentBlock::Text(acp::TextContent::new(text)),
1701        "thought",
1702    )))
1703}
1704
1705fn seed_bash_tool(id: &str) -> AcpEvent {
1706    let mut tool_call = ToolCallUpdate::new(id.to_string()).title(format!("Run {id}"));
1707    tool_call.name = MaybeUndefined::Value("bash".into());
1708    tool_call.raw_input = MaybeUndefined::Value(json!({ "command": "cargo test --module writer" }));
1709    session_update(SessionUpdate::ToolCallUpdate(tool_call))
1710}
1711
1712fn seed_edit_tool(id: &str, turn: usize) -> AcpEvent {
1713    session_update(SessionUpdate::ToolCallUpdate(
1714        ToolCallUpdate::new(id.to_string()).title(format!("Editing src/module_{turn}.rs")),
1715    ))
1716}
1717
1718fn seed_spawn_tool(id: &str) -> AcpEvent {
1719    let mut tool_call = ToolCallUpdate::new(id.to_string()).title(format!("Spawning sub-agents ({id})"));
1720    tool_call.name = MaybeUndefined::Value("spawn_subagent".into());
1721    session_update(SessionUpdate::ToolCallUpdate(tool_call))
1722}
1723
1724pub fn text_diff(path: &str, old: &str, new: &str) -> acp::Diff {
1725    let diff_text = git_patch_from_texts(path, Some(old), Some(new)).expect("valid text diff");
1726    acp::Diff::new(vec![acp::DiffChange::modify(acp::AbsolutePath::new(path))])
1727        .with_patch(diff_text.map(acp::DiffPatch::new))
1728}
1729
1730pub fn tool_completed(id: &str) -> AcpEvent {
1731    session_update(acp::SessionUpdate::ToolCallUpdate(
1732        acp::ToolCallUpdate::new(id.to_string()).status(acp::ToolCallStatus::Completed),
1733    ))
1734}
1735
1736fn seed_tool_diff(id: &str, turn: usize) -> AcpEvent {
1737    let diff = text_diff(&format!("/src/module_{turn}.rs"), SEED_DIFF_BEFORE, SEED_DIFF_AFTER);
1738    session_update(acp::SessionUpdate::ToolCallUpdate(
1739        acp::ToolCallUpdate::new(id.to_string())
1740            .content(vec![acp::ToolCallContent::Diff(diff)])
1741            .status(acp::ToolCallStatus::Completed),
1742    ))
1743}
1744
1745fn seed_sub_agent(parent: &str, task: &str, agent: &str, event: SubAgentEvent) -> AcpEvent {
1746    AcpEvent::SubAgentProgress(SubAgentProgressParams {
1747        parent_tool_id: parent.to_string(),
1748        task_id: task.to_string(),
1749        agent_name: agent.to_string(),
1750        event,
1751    })
1752}
1753
1754fn prose_message(total_bytes: usize) -> String {
1755    let mut message = String::new();
1756    let mut sentence = 0;
1757    while message.len() < total_bytes {
1758        for _ in 0..4 {
1759            let _ =
1760                write!(message, "Sentence {sentence} carries ordinary words so wrapping and parsing do real work. ");
1761            sentence += 1;
1762        }
1763        message.push_str("\n\n");
1764    }
1765    message
1766}
1767
1768fn code_block_message(total_bytes: usize) -> String {
1769    let mut message = String::from("```rust\n");
1770    let mut line = 0;
1771    while message.len() < total_bytes {
1772        let _ = writeln!(message, "let value_{line} = state.reconcile(incoming[{line}]).expect(\"delta accepted\");");
1773        line += 1;
1774    }
1775    message.push_str("```\n");
1776    message
1777}
1778
1779fn thought_message(total_bytes: usize) -> String {
1780    let mut message = String::new();
1781    let mut step = 0;
1782    while message.len() < total_bytes {
1783        let _ = writeln!(message, "Considering step {step} of the plan before acting on it.");
1784        step += 1;
1785    }
1786    message
1787}
1788
1789pub fn chunk_message(message: &str, chunk_bytes: usize) -> Vec<String> {
1790    let mut chunks = Vec::new();
1791    let mut rest = message;
1792    while !rest.is_empty() {
1793        let mut end = rest.len().min(chunk_bytes);
1794        while !rest.is_char_boundary(end) {
1795            end -= 1;
1796        }
1797        chunks.push(rest[..end].to_string());
1798        rest = &rest[end..];
1799    }
1800    chunks
1801}
1802
1803#[cfg(test)]
1804mod tests {
1805    use super::*;
1806    use crate::command::TerminalCommand;
1807    use crate::git_review::{FileStatus, StageState};
1808
1809    #[test]
1810    fn fake_executor_preserves_command_order() {
1811        let mut executor = FakeExecutor::new();
1812        executor
1813            .record([Command::Filesystem(FilesystemCommand::ListThemes), Command::Terminal(TerminalCommand::RingBell)]);
1814
1815        assert!(matches!(executor.take_commands()[..], [Command::Filesystem(_), Command::Terminal(_)]));
1816    }
1817
1818    #[test]
1819    fn fake_filesystem_persists_files_and_settings_in_memory() {
1820        let mut filesystem = FakeFilesystem::new();
1821        let path = PathBuf::from("workspace/src/main.rs");
1822        filesystem.write_file(&path, "fn main() {}");
1823        filesystem.save_settings(UiSettings::default());
1824
1825        assert_eq!(filesystem.read_to_string(&path).as_deref(), Some("fn main() {}"));
1826        assert!(filesystem.contains(Path::new("workspace/src")));
1827        assert!(filesystem.settings().is_some());
1828    }
1829
1830    #[test]
1831    fn fake_git_models_staging_and_discarding_state() {
1832        let mut git = FakeGit::new("workspace");
1833        git.add_file("src/main.rs", "initial\n");
1834        assert_eq!(git.status("src/main.rs"), Some((FileStatus::Untracked, StageState::Unstaged)));
1835
1836        git.stage("src/main.rs");
1837        assert_eq!(git.status("src/main.rs"), Some((FileStatus::Untracked, StageState::Staged)));
1838        git.commit("initial").unwrap();
1839
1840        git.write_file("src/main.rs", "changed\n");
1841        assert_eq!(git.status("src/main.rs"), Some((FileStatus::Modified, StageState::Unstaged)));
1842        git.discard("src/main.rs");
1843        assert_eq!(git.file("src/main.rs").and_then(|file| file.contents), Some(b"initial\n".to_vec()));
1844    }
1845}