393 lines
13 KiB
Rust
393 lines
13 KiB
Rust
use std::sync::Arc;
|
|
|
|
use serde_json::Value;
|
|
use session_sharing_protocol::common::{Scrollback, ScrollbackBlock};
|
|
use url::Url;
|
|
use warpui::r#async::executor::Background;
|
|
use warpui::units::Lines;
|
|
|
|
use super::{decode_scrollback, SharedSessionScrollbackType};
|
|
use crate::ai::blocklist::agent_view::AgentViewState;
|
|
use crate::assert_lines_approx_eq;
|
|
use crate::channel::ChannelState;
|
|
use crate::terminal::color::List;
|
|
use crate::terminal::event_listener::ChannelEventListener;
|
|
use crate::terminal::model::block::SerializedBlock;
|
|
use crate::terminal::model::test_utils::block_size;
|
|
use crate::terminal::model::ObfuscateSecrets;
|
|
use crate::terminal::TerminalModel;
|
|
use crate::themes::default_themes::dark_theme;
|
|
use crate::uri::web_intent_parser::maybe_rewrite_web_url_to_intent;
|
|
|
|
pub const MAX_BYTES_SHAREABLE: usize = 5000;
|
|
|
|
#[test]
|
|
fn maybe_rewrite_web_url_to_shared_session_intent_rewrites_matching_web_url() {
|
|
let server_root = ChannelState::server_root_url();
|
|
let web_url = Url::parse(&format!(
|
|
"{server_root}/session/00000000-0000-0000-0000-000000000000?pwd=secret&preview=true"
|
|
))
|
|
.expect("valid shared session web URL");
|
|
|
|
let maybe_intent = maybe_rewrite_web_url_to_intent(&web_url)
|
|
.expect("expected shared session web URL to rewrite to an intent URL");
|
|
|
|
assert_eq!(maybe_intent.scheme(), ChannelState::url_scheme());
|
|
assert_eq!(maybe_intent.host_str(), Some("shared_session"));
|
|
assert_eq!(maybe_intent.path(), "/00000000-0000-0000-0000-000000000000");
|
|
assert_eq!(maybe_intent.query(), Some("pwd=secret&preview=true"));
|
|
}
|
|
|
|
#[test]
|
|
fn maybe_rewrite_web_url_to_shared_session_intent_ignores_non_matching_host() {
|
|
let web_url =
|
|
Url::parse("https://example.com/session/00000000-0000-0000-0000-000000000000?pwd=secret")
|
|
.expect("valid web URL with non-matching host");
|
|
|
|
let maybe_intent = maybe_rewrite_web_url_to_intent(&web_url);
|
|
|
|
assert!(maybe_intent.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn maybe_rewrite_web_url_to_shared_session_intent_ignores_invalid_session_id() {
|
|
let server_root = ChannelState::server_root_url();
|
|
let web_url = Url::parse(&format!(
|
|
"{server_root}/session/not-a-valid-session-id?pwd=secret&preview=true",
|
|
))
|
|
.expect("valid web URL with invalid session id path segment");
|
|
|
|
let maybe_intent = maybe_rewrite_web_url_to_intent(&web_url);
|
|
|
|
assert!(maybe_intent.is_none());
|
|
}
|
|
|
|
pub fn terminal_model_for_viewer(event_proxy: ChannelEventListener) -> TerminalModel {
|
|
TerminalModel::new_for_shared_session_viewer(
|
|
block_size(),
|
|
List::from(&dark_theme().into()),
|
|
event_proxy,
|
|
Arc::new(Background::default()),
|
|
false, /* show_memory_stats */
|
|
false, /* honor_ps1 */
|
|
false, /* is_inverted */
|
|
ObfuscateSecrets::No,
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_no_scrollback() {
|
|
let restored_blocks = &[SerializedBlock::new_for_test("a".into(), "b".into()).into()];
|
|
let channel_event_proxy = ChannelEventListener::new_for_test();
|
|
let mut model = TerminalModel::mock(Some(restored_blocks), Some(channel_event_proxy));
|
|
|
|
model.simulate_block("block1", "block1");
|
|
model.simulate_block("block2", "block2");
|
|
|
|
let scrollback = SharedSessionScrollbackType::None.to_scrollback(&model);
|
|
// Should only contain the active block
|
|
assert_eq!(scrollback.blocks.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_scrollback_starting_at_block() {
|
|
let restored_blocks = &[SerializedBlock::new_for_test("a".into(), "b".into()).into()];
|
|
let channel_event_proxy = ChannelEventListener::new_for_test();
|
|
let mut model = TerminalModel::mock(Some(restored_blocks), Some(channel_event_proxy));
|
|
|
|
model.simulate_block("block1", "block1");
|
|
model.simulate_block("block2", "block2");
|
|
|
|
let starting_block = model
|
|
.block_list()
|
|
.last_non_hidden_block()
|
|
.expect("there is a non-hidden block");
|
|
let scrollback = SharedSessionScrollbackType::FromBlock {
|
|
block_index: starting_block.index(),
|
|
}
|
|
.to_scrollback(&model);
|
|
|
|
// Should contain 1 completed block + active block
|
|
assert_eq!(scrollback.blocks.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_all_scrollback() {
|
|
let restored_blocks = &[SerializedBlock::new_for_test("a".into(), "b".into()).into()];
|
|
let channel_event_proxy = ChannelEventListener::new_for_test();
|
|
let mut model = TerminalModel::mock(Some(restored_blocks), Some(channel_event_proxy));
|
|
|
|
// Restored blocks and bootstrap blocks don't count towards scrollback,
|
|
let scrollback = SharedSessionScrollbackType::All.to_scrollback(&model);
|
|
// Only active block
|
|
assert_eq!(scrollback.blocks.len(), 1);
|
|
|
|
model.simulate_block("block1", "block1");
|
|
let scrollback = SharedSessionScrollbackType::All.to_scrollback(&model);
|
|
assert_eq!(scrollback.blocks.len(), 2);
|
|
|
|
model.simulate_block("block2", "block2");
|
|
let scrollback = SharedSessionScrollbackType::All.to_scrollback(&model);
|
|
|
|
// Should contain 2 completed blocks + active block
|
|
assert_eq!(scrollback.blocks.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_scrollback_round_trip() {
|
|
let channel_event_proxy = ChannelEventListener::new_for_test();
|
|
let mut model = TerminalModel::mock(None, Some(channel_event_proxy));
|
|
|
|
model.simulate_block("hello", "world");
|
|
|
|
// Capture the expected stylized bytes from the completed block before serialization.
|
|
let completed_block = model.block_list().block_at(1.into()).unwrap();
|
|
let expected: SerializedBlock = completed_block.into();
|
|
|
|
let scrollback = SharedSessionScrollbackType::All.to_scrollback(&model);
|
|
let decoded = decode_scrollback(&scrollback);
|
|
|
|
// The completed block is first; the active (empty) block is second.
|
|
assert_eq!(decoded.len(), 2);
|
|
assert_eq!(decoded[0].stylized_command, expected.stylized_command);
|
|
assert_eq!(decoded[0].stylized_output, expected.stylized_output);
|
|
}
|
|
|
|
#[test]
|
|
fn test_scrollback_serialization() {
|
|
let channel_event_proxy = ChannelEventListener::new_for_test();
|
|
let mut model = TerminalModel::mock(None, Some(channel_event_proxy));
|
|
|
|
model.simulate_block("hello", "world");
|
|
|
|
let scrollback = SharedSessionScrollbackType::All.to_scrollback(&model);
|
|
let first_block = scrollback
|
|
.blocks
|
|
.first()
|
|
.expect("expected first scrollback block");
|
|
let json: Value = serde_json::from_slice(&first_block.raw).expect("valid scrollback json");
|
|
|
|
// Capture the expected bytes from the model so we can assert exact JSON array contents.
|
|
let completed_block = model.block_list().block_at(1.into()).unwrap();
|
|
let expected: SerializedBlock = completed_block.into();
|
|
|
|
let expected_command: Vec<Value> = expected
|
|
.stylized_command
|
|
.iter()
|
|
.map(|&b| Value::from(b))
|
|
.collect();
|
|
let expected_output: Vec<Value> = expected
|
|
.stylized_output
|
|
.iter()
|
|
.map(|&b| Value::from(b))
|
|
.collect();
|
|
|
|
assert_eq!(
|
|
json.get("stylized_command"),
|
|
Some(&Value::Array(expected_command)),
|
|
);
|
|
assert_eq!(
|
|
json.get("stylized_output"),
|
|
Some(&Value::Array(expected_output)),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_scrollback_deserialization() {
|
|
let raw = serde_json::json!({
|
|
"id": "00000000-0000-0000-0000-000000000000",
|
|
"stylized_command": [104, 101, 108, 108, 111],
|
|
"stylized_output": [119, 111, 114, 108, 100],
|
|
"pwd": null,
|
|
"git_head": null,
|
|
"virtual_env": null,
|
|
"conda_env": null,
|
|
"node_version": null,
|
|
"exit_code": 0,
|
|
"did_execute": true,
|
|
"completed_ts": null,
|
|
"start_ts": null,
|
|
"ps1": null,
|
|
"rprompt": null,
|
|
"honor_ps1": false,
|
|
"is_background": false,
|
|
"session_id": null,
|
|
"shell_host": null,
|
|
"prompt_snapshot": null,
|
|
"ai_metadata": null
|
|
});
|
|
|
|
let scrollback = Scrollback {
|
|
blocks: vec![ScrollbackBlock {
|
|
raw: serde_json::to_vec(&raw).expect("serialize scrollback json"),
|
|
}],
|
|
is_alt_screen_active: false,
|
|
};
|
|
|
|
let decoded = decode_scrollback(&scrollback);
|
|
|
|
assert_eq!(decoded.len(), 1);
|
|
assert_eq!(decoded[0].stylized_command, b"hello");
|
|
assert_eq!(decoded[0].stylized_output, b"world");
|
|
}
|
|
|
|
#[test]
|
|
fn test_loading_scrollback() {
|
|
let session_id = 42.into();
|
|
let mut active_block = SerializedBlock::new_active_block_for_test();
|
|
active_block.session_id = Some(session_id);
|
|
|
|
let scrollback_blocks = &[
|
|
SerializedBlock::new_for_test("block1".into(), "block1".into()),
|
|
SerializedBlock::new_for_test("block2".into(), "block2".into()),
|
|
// We expect the active block as part of scrollback to get the prompt.
|
|
active_block,
|
|
];
|
|
let channel_event_proxy = ChannelEventListener::new_for_test();
|
|
let mut model = terminal_model_for_viewer(channel_event_proxy);
|
|
model.load_shared_session_scrollback(scrollback_blocks);
|
|
|
|
// 4 blocks: first is the bootstrap block, the next two are completed scrollback blocks.
|
|
// The last is the active block, whose prompt came from the last scrollback.
|
|
assert_eq!(model.block_list().blocks().len(), 4);
|
|
|
|
assert_eq!(
|
|
model
|
|
.block_list()
|
|
.block_at(1.into())
|
|
.unwrap()
|
|
.command_to_string(),
|
|
"block1"
|
|
);
|
|
assert_eq!(
|
|
model
|
|
.block_list()
|
|
.block_at(1.into())
|
|
.unwrap()
|
|
.output_to_string(),
|
|
"block1"
|
|
);
|
|
|
|
assert_eq!(
|
|
model
|
|
.block_list()
|
|
.block_at(2.into())
|
|
.unwrap()
|
|
.command_to_string(),
|
|
"block2"
|
|
);
|
|
assert_eq!(
|
|
model
|
|
.block_list()
|
|
.block_at(2.into())
|
|
.unwrap()
|
|
.output_to_string(),
|
|
"block2"
|
|
);
|
|
|
|
// The last scrollback block is the active block and contains the prompt.
|
|
assert_eq!(model.block_list().active_block_index(), 3.into());
|
|
assert_eq!(
|
|
model
|
|
.block_list()
|
|
.active_block()
|
|
.height(&AgentViewState::Inactive),
|
|
Lines::zero()
|
|
);
|
|
assert!(!model.block_list().active_block().started());
|
|
assert_eq!(
|
|
model.block_list().active_block().session_id(),
|
|
Some(session_id)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_loading_scrollback_with_completed_last_block_creates_active_block() {
|
|
let scrollback_blocks = &[
|
|
SerializedBlock::new_for_test("block1".into(), "block1".into()),
|
|
SerializedBlock::new_for_test("block2".into(), "block2".into()),
|
|
];
|
|
let channel_event_proxy = ChannelEventListener::new_for_test();
|
|
let mut model = terminal_model_for_viewer(channel_event_proxy);
|
|
model.load_shared_session_scrollback(scrollback_blocks);
|
|
|
|
// 4 blocks: first is the bootstrap block, the next two are completed scrollback blocks.
|
|
// Since no active block was serialized, restore creates a fresh active block.
|
|
assert_eq!(model.block_list().blocks().len(), 4);
|
|
|
|
assert_eq!(
|
|
model
|
|
.block_list()
|
|
.block_at(1.into())
|
|
.unwrap()
|
|
.command_to_string(),
|
|
"block1"
|
|
);
|
|
assert_eq!(
|
|
model
|
|
.block_list()
|
|
.block_at(2.into())
|
|
.unwrap()
|
|
.command_to_string(),
|
|
"block2"
|
|
);
|
|
|
|
assert_eq!(model.block_list().active_block_index(), 3.into());
|
|
assert_eq!(
|
|
model
|
|
.block_list()
|
|
.active_block()
|
|
.height(&AgentViewState::Inactive),
|
|
Lines::zero()
|
|
);
|
|
assert!(!model.block_list().active_block().started());
|
|
}
|
|
|
|
#[test]
|
|
fn test_loading_scrollback_in_alt_screen() {
|
|
let scrollback_blocks = &[
|
|
SerializedBlock::new_for_test("block1".into(), "block1".into()),
|
|
// We expect the active block as part of scrollback to get the prompt.
|
|
SerializedBlock::new_active_block_for_test(),
|
|
];
|
|
let channel_event_proxy = ChannelEventListener::new_for_test();
|
|
let mut model = terminal_model_for_viewer(channel_event_proxy);
|
|
model.load_shared_session_scrollback(scrollback_blocks);
|
|
model.enter_alt_screen(true);
|
|
|
|
// 3 blocks: first is the bootstrap block, the second is the completed scrollback blocks.
|
|
// The last is the active block, whose prompt came from the last scrollback.
|
|
assert_eq!(model.block_list().blocks().len(), 3);
|
|
|
|
assert_eq!(
|
|
model
|
|
.block_list()
|
|
.block_at(1.into())
|
|
.unwrap()
|
|
.command_to_string(),
|
|
"block1"
|
|
);
|
|
assert_eq!(
|
|
model
|
|
.block_list()
|
|
.block_at(1.into())
|
|
.unwrap()
|
|
.output_to_string(),
|
|
"block1"
|
|
);
|
|
|
|
// The last scrollback block is the active block and contains the prompt.
|
|
assert_lines_approx_eq!(
|
|
model
|
|
.block_list()
|
|
.block_at(2.into())
|
|
.unwrap()
|
|
.height(&AgentViewState::Inactive),
|
|
0.
|
|
);
|
|
assert!(!model.block_list().block_at(2.into()).unwrap().started());
|
|
|
|
// Make sure we're in the alt screen.
|
|
assert!(model.is_alt_screen_active());
|
|
}
|