Skip to main content

wisp/
testing.rs

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