repl: Test outputs and ExecutionView (#48892)

Kyle Kelley created

More tests for outputs and the `ExecutionView`. Wanting to get more of
these in before we progress on Notebooks and Widgets.

Release Notes:

- N/A

Change summary

crates/repl/Cargo.toml     |   1 
crates/repl/src/outputs.rs | 318 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 319 insertions(+)

Detailed changes

crates/repl/Cargo.toml 🔗

@@ -72,4 +72,5 @@ theme = { workspace = true, features = ["test-support"] }
 tree-sitter-md.workspace = true
 tree-sitter-typescript.workspace = true
 tree-sitter-python.workspace = true
+workspace = { workspace = true, features = ["test-support"] }
 util = { workspace = true, features = ["test-support"] }

crates/repl/src/outputs.rs 🔗

@@ -646,6 +646,19 @@ impl ExecutionView {
     }
 }
 
+impl ExecutionView {
+    #[cfg(test)]
+    fn output_as_stream_text(&self, cx: &App) -> Option<String> {
+        self.outputs.iter().find_map(|output| {
+            if let Output::Stream { content } = output {
+                Some(content.read(cx).full_text())
+            } else {
+                None
+            }
+        })
+    }
+}
+
 impl Render for ExecutionView {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let status = match &self.status {
@@ -708,3 +721,308 @@ impl Render for ExecutionView {
             .into_any_element()
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use gpui::TestAppContext;
+    use runtimelib::{
+        ClearOutput, ErrorOutput, ExecutionState, JupyterMessageContent, MimeType, Status, Stdio,
+        StreamContent,
+    };
+    use settings::SettingsStore;
+    use std::path::Path;
+    use std::sync::Arc;
+
+    #[test]
+    fn test_rank_mime_type_ordering() {
+        let data_table = MimeType::DataTable(Box::default());
+        let json = MimeType::Json(serde_json::json!({}));
+        let png = MimeType::Png(String::new());
+        let jpeg = MimeType::Jpeg(String::new());
+        let markdown = MimeType::Markdown(String::new());
+        let plain = MimeType::Plain(String::new());
+
+        assert_eq!(rank_mime_type(&data_table), 6);
+        assert_eq!(rank_mime_type(&json), 5);
+        assert_eq!(rank_mime_type(&png), 4);
+        assert_eq!(rank_mime_type(&jpeg), 3);
+        assert_eq!(rank_mime_type(&markdown), 2);
+        assert_eq!(rank_mime_type(&plain), 1);
+
+        assert!(rank_mime_type(&data_table) > rank_mime_type(&json));
+        assert!(rank_mime_type(&json) > rank_mime_type(&png));
+        assert!(rank_mime_type(&png) > rank_mime_type(&jpeg));
+        assert!(rank_mime_type(&jpeg) > rank_mime_type(&markdown));
+        assert!(rank_mime_type(&markdown) > rank_mime_type(&plain));
+    }
+
+    #[test]
+    fn test_rank_mime_type_unsupported_returns_zero() {
+        let html = MimeType::Html(String::new());
+        let svg = MimeType::Svg(String::new());
+        let latex = MimeType::Latex(String::new());
+
+        assert_eq!(rank_mime_type(&html), 0);
+        assert_eq!(rank_mime_type(&svg), 0);
+        assert_eq!(rank_mime_type(&latex), 0);
+    }
+
+    async fn init_test(
+        cx: &mut TestAppContext,
+    ) -> (gpui::VisualTestContext, WeakEntity<workspace::Workspace>) {
+        cx.update(|cx| {
+            let settings_store = SettingsStore::test(cx);
+            cx.set_global(settings_store);
+            theme::init(theme::LoadThemes::JustBase, cx);
+        });
+        let fs = project::FakeFs::new(cx.background_executor.clone());
+        let project = project::Project::test(fs, [] as [&Path; 0], cx).await;
+        let window =
+            cx.add_window(|window, cx| workspace::Workspace::test_new(project, window, cx));
+        let workspace = window.root(cx).expect("workspace should exist");
+        let weak_workspace = workspace.downgrade();
+        let visual_cx = gpui::VisualTestContext::from_window(window.into(), cx);
+        (visual_cx, weak_workspace)
+    }
+
+    fn create_execution_view(
+        cx: &mut gpui::VisualTestContext,
+        weak_workspace: WeakEntity<workspace::Workspace>,
+    ) -> Entity<ExecutionView> {
+        cx.update(|_window, cx| {
+            cx.new(|cx| ExecutionView::new(ExecutionStatus::Queued, weak_workspace, cx))
+        })
+    }
+
+    #[gpui::test]
+    async fn test_push_message_stream_content(cx: &mut TestAppContext) {
+        let (mut cx, workspace) = init_test(cx).await;
+        let execution_view = create_execution_view(&mut cx, workspace);
+
+        cx.update(|window, cx| {
+            execution_view.update(cx, |view, cx| {
+                let message = JupyterMessageContent::StreamContent(StreamContent {
+                    name: Stdio::Stdout,
+                    text: "hello world\n".to_string(),
+                });
+                view.push_message(&message, window, cx);
+            });
+        });
+
+        cx.update(|_, cx| {
+            let view = execution_view.read(cx);
+            assert_eq!(view.outputs.len(), 1);
+            assert!(matches!(view.outputs[0], Output::Stream { .. }));
+            let text = view.output_as_stream_text(cx);
+            assert!(text.is_some());
+            assert!(text.as_ref().is_some_and(|t| t.contains("hello world")));
+        });
+    }
+
+    #[gpui::test]
+    async fn test_push_message_stream_appends(cx: &mut TestAppContext) {
+        let (mut cx, workspace) = init_test(cx).await;
+        let execution_view = create_execution_view(&mut cx, workspace);
+
+        cx.update(|window, cx| {
+            execution_view.update(cx, |view, cx| {
+                let message1 = JupyterMessageContent::StreamContent(StreamContent {
+                    name: Stdio::Stdout,
+                    text: "first ".to_string(),
+                });
+                let message2 = JupyterMessageContent::StreamContent(StreamContent {
+                    name: Stdio::Stdout,
+                    text: "second".to_string(),
+                });
+                view.push_message(&message1, window, cx);
+                view.push_message(&message2, window, cx);
+            });
+        });
+
+        cx.update(|_, cx| {
+            let view = execution_view.read(cx);
+            assert_eq!(
+                view.outputs.len(),
+                1,
+                "consecutive streams should merge into one output"
+            );
+            let text = view.output_as_stream_text(cx);
+            assert!(text.as_ref().is_some_and(|t| t.contains("first ")));
+            assert!(text.as_ref().is_some_and(|t| t.contains("second")));
+        });
+    }
+
+    #[gpui::test]
+    async fn test_push_message_error_output(cx: &mut TestAppContext) {
+        let (mut cx, workspace) = init_test(cx).await;
+        let execution_view = create_execution_view(&mut cx, workspace);
+
+        cx.update(|window, cx| {
+            execution_view.update(cx, |view, cx| {
+                let message = JupyterMessageContent::ErrorOutput(ErrorOutput {
+                    ename: "NameError".to_string(),
+                    evalue: "name 'x' is not defined".to_string(),
+                    traceback: vec![
+                        "Traceback (most recent call last):".to_string(),
+                        "NameError: name 'x' is not defined".to_string(),
+                    ],
+                });
+                view.push_message(&message, window, cx);
+            });
+        });
+
+        cx.update(|_, cx| {
+            let view = execution_view.read(cx);
+            assert_eq!(view.outputs.len(), 1);
+            match &view.outputs[0] {
+                Output::ErrorOutput(error_view) => {
+                    assert_eq!(error_view.ename, "NameError");
+                    assert_eq!(error_view.evalue, "name 'x' is not defined");
+                }
+                other => panic!(
+                    "expected ErrorOutput, got {:?}",
+                    std::mem::discriminant(other)
+                ),
+            }
+        });
+    }
+
+    #[gpui::test]
+    async fn test_push_message_clear_output_immediate(cx: &mut TestAppContext) {
+        let (mut cx, workspace) = init_test(cx).await;
+        let execution_view = create_execution_view(&mut cx, workspace);
+
+        cx.update(|window, cx| {
+            execution_view.update(cx, |view, cx| {
+                let stream = JupyterMessageContent::StreamContent(StreamContent {
+                    name: Stdio::Stdout,
+                    text: "some output\n".to_string(),
+                });
+                view.push_message(&stream, window, cx);
+                assert_eq!(view.outputs.len(), 1);
+
+                let clear = JupyterMessageContent::ClearOutput(ClearOutput { wait: false });
+                view.push_message(&clear, window, cx);
+                assert_eq!(
+                    view.outputs.len(),
+                    0,
+                    "immediate clear should remove all outputs"
+                );
+            });
+        });
+    }
+
+    #[gpui::test]
+    async fn test_push_message_clear_output_deferred(cx: &mut TestAppContext) {
+        let (mut cx, workspace) = init_test(cx).await;
+        let execution_view = create_execution_view(&mut cx, workspace);
+
+        cx.update(|window, cx| {
+            execution_view.update(cx, |view, cx| {
+                let stream = JupyterMessageContent::StreamContent(StreamContent {
+                    name: Stdio::Stdout,
+                    text: "old output\n".to_string(),
+                });
+                view.push_message(&stream, window, cx);
+                assert_eq!(view.outputs.len(), 1);
+
+                let clear = JupyterMessageContent::ClearOutput(ClearOutput { wait: true });
+                view.push_message(&clear, window, cx);
+                assert_eq!(view.outputs.len(), 2, "deferred clear adds a wait marker");
+                assert!(matches!(view.outputs[1], Output::ClearOutputWaitMarker));
+
+                let new_stream = JupyterMessageContent::StreamContent(StreamContent {
+                    name: Stdio::Stdout,
+                    text: "new output\n".to_string(),
+                });
+                view.push_message(&new_stream, window, cx);
+                assert_eq!(
+                    view.outputs.len(),
+                    1,
+                    "next output after wait marker should clear previous outputs"
+                );
+            });
+        });
+    }
+
+    #[gpui::test]
+    async fn test_push_message_status_transitions(cx: &mut TestAppContext) {
+        let (mut cx, workspace) = init_test(cx).await;
+        let execution_view = create_execution_view(&mut cx, workspace);
+
+        cx.update(|window, cx| {
+            execution_view.update(cx, |view, cx| {
+                let busy = JupyterMessageContent::Status(Status {
+                    execution_state: ExecutionState::Busy,
+                });
+                view.push_message(&busy, window, cx);
+                assert!(matches!(view.status, ExecutionStatus::Executing));
+
+                let idle = JupyterMessageContent::Status(Status {
+                    execution_state: ExecutionState::Idle,
+                });
+                view.push_message(&idle, window, cx);
+                assert!(matches!(view.status, ExecutionStatus::Finished));
+
+                let starting = JupyterMessageContent::Status(Status {
+                    execution_state: ExecutionState::Starting,
+                });
+                view.push_message(&starting, window, cx);
+                assert!(matches!(view.status, ExecutionStatus::ConnectingToKernel));
+
+                let dead = JupyterMessageContent::Status(Status {
+                    execution_state: ExecutionState::Dead,
+                });
+                view.push_message(&dead, window, cx);
+                assert!(matches!(view.status, ExecutionStatus::Shutdown));
+
+                let restarting = JupyterMessageContent::Status(Status {
+                    execution_state: ExecutionState::Restarting,
+                });
+                view.push_message(&restarting, window, cx);
+                assert!(matches!(view.status, ExecutionStatus::Restarting));
+
+                let terminating = JupyterMessageContent::Status(Status {
+                    execution_state: ExecutionState::Terminating,
+                });
+                view.push_message(&terminating, window, cx);
+                assert!(matches!(view.status, ExecutionStatus::ShuttingDown));
+            });
+        });
+    }
+
+    #[gpui::test]
+    async fn test_push_message_status_idle_emits_finished_empty(cx: &mut TestAppContext) {
+        let (mut cx, workspace) = init_test(cx).await;
+        let execution_view = create_execution_view(&mut cx, workspace);
+
+        let emitted = Arc::new(std::sync::atomic::AtomicBool::new(false));
+        let emitted_clone = emitted.clone();
+
+        cx.update(|_, cx| {
+            cx.subscribe(
+                &execution_view,
+                move |_, _event: &ExecutionViewFinishedEmpty, _cx| {
+                    emitted_clone.store(true, std::sync::atomic::Ordering::SeqCst);
+                },
+            )
+            .detach();
+        });
+
+        cx.update(|window, cx| {
+            execution_view.update(cx, |view, cx| {
+                assert!(view.outputs.is_empty());
+                let idle = JupyterMessageContent::Status(Status {
+                    execution_state: ExecutionState::Idle,
+                });
+                view.push_message(&idle, window, cx);
+            });
+        });
+
+        assert!(
+            emitted.load(std::sync::atomic::Ordering::SeqCst),
+            "should emit ExecutionViewFinishedEmpty when idle with no outputs"
+        );
+    }
+}