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