From de5fc22335c637f66c0fddefe925ca77cb0b9c1b Mon Sep 17 00:00:00 2001 From: Kyle Kelley Date: Wed, 11 Feb 2026 08:22:26 -0800 Subject: [PATCH] repl: Test outputs and ExecutionView (#48892) 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 --- crates/repl/Cargo.toml | 1 + crates/repl/src/outputs.rs | 318 +++++++++++++++++++++++++++++++++++++ 2 files changed, 319 insertions(+) diff --git a/crates/repl/Cargo.toml b/crates/repl/Cargo.toml index e03676ba3bd26135b327969afde569a01c57dc6f..1987ae1a4cb0c3ee963df9983665cd087d7e47b0 100644 --- a/crates/repl/Cargo.toml +++ b/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"] } diff --git a/crates/repl/src/outputs.rs b/crates/repl/src/outputs.rs index b4d759a2761fc571de59fe2c8d2749deec7dfbea..2dfc0abb19b62f0e1401920f07c22c83dc68dfb3 100644 --- a/crates/repl/src/outputs.rs +++ b/crates/repl/src/outputs.rs @@ -646,6 +646,19 @@ impl ExecutionView { } } +impl ExecutionView { + #[cfg(test)] + fn output_as_stream_text(&self, cx: &App) -> Option { + 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) -> 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) { + 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, + ) -> Entity { + 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" + ); + } +}