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