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