2904 lines
97 KiB
Rust
2904 lines
97 KiB
Rust
use makepad_studio_hub::{HubConfig, MountConfig, StudioHub};
|
|
use makepad_studio_protocol::hub_protocol::{ClientToHub, HubToClient, TerminalFramebuffer};
|
|
use std::fs;
|
|
use std::time::{Duration, Instant};
|
|
|
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
fn terminal_test_shell_env(dir: &std::path::Path) -> std::collections::HashMap<String, String> {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let shell_path = dir.join("makepad_test_shell.sh");
|
|
fs::write(
|
|
&shell_path,
|
|
"#!/bin/sh\nif [ \"$1\" = \"-l\" ]; then\n shift\nfi\nexport PS1='$ '\nexec /bin/sh -i \"$@\"\n",
|
|
)
|
|
.unwrap();
|
|
let mut perms = fs::metadata(&shell_path).unwrap().permissions();
|
|
perms.set_mode(0o755);
|
|
fs::set_permissions(&shell_path, perms).unwrap();
|
|
|
|
let mut env = std::collections::HashMap::new();
|
|
env.insert(
|
|
"SHELL".to_string(),
|
|
shell_path.to_string_lossy().to_string(),
|
|
);
|
|
env
|
|
}
|
|
|
|
fn wait_for_message<F>(
|
|
connection: &makepad_studio_hub::HubConnection,
|
|
timeout: Duration,
|
|
mut matcher: F,
|
|
) -> Option<HubToClient>
|
|
where
|
|
F: FnMut(&HubToClient) -> bool,
|
|
{
|
|
let deadline = Instant::now() + timeout;
|
|
while Instant::now() < deadline {
|
|
if let Some(msg) = connection.recv_timeout(Duration::from_millis(100)) {
|
|
if matcher(&msg) {
|
|
return Some(msg);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn drain_messages(
|
|
connection: &makepad_studio_hub::HubConnection,
|
|
duration: Duration,
|
|
) -> Vec<HubToClient> {
|
|
let deadline = Instant::now() + duration;
|
|
let mut out = Vec::new();
|
|
while Instant::now() < deadline {
|
|
if let Some(msg) = connection.recv_timeout(Duration::from_millis(50)) {
|
|
out.push(msg);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn request_terminal_viewport(
|
|
connection: &mut makepad_studio_hub::HubConnection,
|
|
path: &str,
|
|
cols: u16,
|
|
rows: u16,
|
|
top_row: usize,
|
|
) {
|
|
let _ = connection.send(ClientToHub::TerminalViewportRequest {
|
|
path: path.to_string(),
|
|
cols,
|
|
rows,
|
|
pty_rows: rows,
|
|
top_row,
|
|
});
|
|
}
|
|
|
|
fn framebuffer_to_text(frame: &TerminalFramebuffer) -> String {
|
|
let cols = frame.cols as usize;
|
|
let rows = frame.rows as usize;
|
|
let mut out = String::with_capacity(cols * rows + rows.saturating_sub(1));
|
|
for row in 0..rows {
|
|
for col in 0..cols {
|
|
let idx = (row * cols + col) * 7;
|
|
if idx + 6 >= frame.cells.len() {
|
|
out.push(' ');
|
|
continue;
|
|
}
|
|
let codepoint = u32::from_le_bytes([
|
|
frame.cells[idx],
|
|
frame.cells[idx + 1],
|
|
frame.cells[idx + 2],
|
|
frame.cells[idx + 3],
|
|
]);
|
|
out.push(char::from_u32(codepoint).unwrap_or(' '));
|
|
}
|
|
if row + 1 < rows {
|
|
out.push('\n');
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn wait_for_terminal_frame_contains(
|
|
connection: &makepad_studio_hub::HubConnection,
|
|
path: &str,
|
|
needle: &str,
|
|
timeout: Duration,
|
|
) -> bool {
|
|
let deadline = Instant::now() + timeout;
|
|
while Instant::now() < deadline {
|
|
let Some(msg) = connection.recv_timeout(Duration::from_millis(100)) else {
|
|
continue;
|
|
};
|
|
match msg {
|
|
HubToClient::TerminalFramebuffer {
|
|
path: output_path,
|
|
frame,
|
|
} if output_path == path => {
|
|
if framebuffer_to_text(&frame).contains(needle) {
|
|
return true;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
fn wait_for_terminal_frame_where<F>(
|
|
connection: &makepad_studio_hub::HubConnection,
|
|
path: &str,
|
|
timeout: Duration,
|
|
mut predicate: F,
|
|
) -> Option<TerminalFramebuffer>
|
|
where
|
|
F: FnMut(&TerminalFramebuffer) -> bool,
|
|
{
|
|
let deadline = Instant::now() + timeout;
|
|
while Instant::now() < deadline {
|
|
let Some(msg) = connection.recv_timeout(Duration::from_millis(100)) else {
|
|
continue;
|
|
};
|
|
match msg {
|
|
HubToClient::TerminalFramebuffer {
|
|
path: output_path,
|
|
frame,
|
|
} if output_path == path => {
|
|
if predicate(&frame) {
|
|
return Some(frame);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn wait_for_terminal_shell_ready(
|
|
connection: &mut makepad_studio_hub::HubConnection,
|
|
path: &str,
|
|
timeout: Duration,
|
|
) -> bool {
|
|
let marker = "__MAKEPAD_TERM_READY__";
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.to_string(),
|
|
data: format!("printf '{}\\n'\n", marker).into_bytes(),
|
|
});
|
|
wait_for_terminal_frame_contains(connection, path, marker, timeout)
|
|
}
|
|
|
|
fn framebuffer_last_row_text(frame: &TerminalFramebuffer) -> String {
|
|
framebuffer_to_text(frame)
|
|
.lines()
|
|
.last()
|
|
.unwrap_or("")
|
|
.trim_end()
|
|
.to_string()
|
|
}
|
|
|
|
fn framebuffer_row_text(frame: &TerminalFramebuffer, row: usize) -> String {
|
|
framebuffer_to_text(frame)
|
|
.lines()
|
|
.nth(row)
|
|
.unwrap_or("")
|
|
.trim_end()
|
|
.to_string()
|
|
}
|
|
|
|
fn tail_lines(text: &str, n: usize) -> String {
|
|
let lines: Vec<&str> = text.lines().collect();
|
|
let start = lines.len().saturating_sub(n);
|
|
lines[start..].join("\n")
|
|
}
|
|
|
|
#[test]
|
|
fn in_process_connection_roundtrip_and_cargo_build_lifecycle() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join("src")).unwrap();
|
|
fs::write(dir.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let _tree_query = connection.send(ClientToHub::LoadFileTree {
|
|
mount: "repo".to_string(),
|
|
});
|
|
let tree = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(3),
|
|
|msg| matches!(msg, HubToClient::FileTree { mount, .. } if mount == "repo"),
|
|
)
|
|
.expect("did not receive file tree");
|
|
match tree {
|
|
HubToClient::FileTree { data, .. } => {
|
|
assert!(data.nodes.iter().any(|node| node.path == "repo/src/lib.rs"));
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
|
|
let _run_query_id = connection.send(ClientToHub::Cargo {
|
|
mount: "repo".to_string(),
|
|
args: vec!["--version".to_string()],
|
|
env: None,
|
|
buildbox: None,
|
|
});
|
|
|
|
let started = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(5),
|
|
|msg| matches!(msg, HubToClient::BuildStarted { mount, .. } if mount == "repo"),
|
|
);
|
|
let build_id = match started {
|
|
Some(HubToClient::BuildStarted { build_id, .. }) => build_id,
|
|
_ => panic!("did not receive BuildStarted"),
|
|
};
|
|
|
|
let stopped = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(10),
|
|
|msg| matches!(msg, HubToClient::BuildStopped { build_id: id, .. } if *id == build_id),
|
|
);
|
|
assert!(stopped.is_some(), "did not receive BuildStopped");
|
|
|
|
let query_id = connection.send(ClientToHub::QueryLogs {
|
|
build_id: Some(build_id),
|
|
level: None,
|
|
source: None,
|
|
file: None,
|
|
pattern: None,
|
|
is_regex: None,
|
|
since_index: None,
|
|
live: Some(false),
|
|
});
|
|
let log_results = wait_for_message(&connection, Duration::from_secs(3), |msg| {
|
|
matches!(
|
|
msg,
|
|
HubToClient::QueryLogResults {
|
|
query_id: id, ..
|
|
} if *id == query_id
|
|
)
|
|
})
|
|
.expect("did not receive QueryLogResults");
|
|
|
|
match log_results {
|
|
HubToClient::QueryLogResults { entries, done, .. } => {
|
|
assert!(done);
|
|
assert!(!entries.is_empty(), "expected cargo output entries");
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
|
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
#[test]
|
|
#[ignore = "manual diagnostic: depends on live interactive shell startup and PTY buffering"]
|
|
fn terminal_large_paste_keeps_session_alive() {
|
|
const LARGE_PASTE_BYTES: usize = 512 * 1024;
|
|
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join("src")).unwrap();
|
|
fs::write(dir.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let path = "repo/.makepad/large_paste.term".to_string();
|
|
let _ = connection.send(ClientToHub::TerminalOpen {
|
|
path: path.clone(),
|
|
cols: 120,
|
|
rows: 30,
|
|
env: terminal_test_shell_env(dir.path()),
|
|
});
|
|
let opened = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(4),
|
|
|msg| matches!(msg, HubToClient::TerminalOpened { path: p, .. } if p == &path),
|
|
);
|
|
assert!(opened.is_some(), "did not receive TerminalOpened");
|
|
request_terminal_viewport(&mut connection, &path, 120, 30, usize::MAX);
|
|
assert!(
|
|
wait_for_terminal_shell_ready(&mut connection, &path, Duration::from_secs(8)),
|
|
"terminal shell was not ready before large paste test"
|
|
);
|
|
|
|
// Disable echo so the large paste does not flood output, stream a large paste
|
|
// into cat, send Ctrl-D, then run a marker command.
|
|
let mut input = b"stty -echo\ncat > /dev/null\n".to_vec();
|
|
input.extend(std::iter::repeat_n(b'x', LARGE_PASTE_BYTES));
|
|
input.push(b'\n');
|
|
// Interrupt cat so we reliably return to shell even if EOF semantics vary.
|
|
input.push(0x03);
|
|
input.extend_from_slice(b"stty echo\necho __paste_ok__\n");
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.clone(),
|
|
data: input,
|
|
});
|
|
|
|
let mut saw_marker = false;
|
|
let deadline = Instant::now() + Duration::from_secs(12);
|
|
while Instant::now() < deadline {
|
|
let Some(msg) = connection.recv_timeout(Duration::from_millis(100)) else {
|
|
continue;
|
|
};
|
|
match msg {
|
|
HubToClient::TerminalExited {
|
|
path: exited_path, ..
|
|
} if exited_path == path => {
|
|
panic!("terminal exited during large paste");
|
|
}
|
|
HubToClient::Error { message }
|
|
if message.contains("unknown terminal") && message.contains(&path) =>
|
|
{
|
|
panic!("terminal lost after large paste: {}", message);
|
|
}
|
|
HubToClient::TerminalFramebuffer {
|
|
path: output_path,
|
|
frame,
|
|
} if output_path == path => {
|
|
if framebuffer_to_text(&frame).contains("__paste_ok__") {
|
|
saw_marker = true;
|
|
break;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
let _ = connection.send(ClientToHub::TerminalClose { path });
|
|
assert!(
|
|
saw_marker,
|
|
"did not observe terminal response after large paste"
|
|
);
|
|
}
|
|
|
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
#[test]
|
|
fn terminal_bell_sets_title_badge_until_next_input() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join("src")).unwrap();
|
|
fs::write(dir.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let path = "repo/.makepad/bell.term".to_string();
|
|
let _ = connection.send(ClientToHub::TerminalOpen {
|
|
path: path.clone(),
|
|
cols: 120,
|
|
rows: 30,
|
|
env: terminal_test_shell_env(dir.path()),
|
|
});
|
|
let opened = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(4),
|
|
|msg| matches!(msg, HubToClient::TerminalOpened { path: p, .. } if p == &path),
|
|
);
|
|
assert!(opened.is_some(), "did not receive TerminalOpened");
|
|
request_terminal_viewport(&mut connection, &path, 120, 30, usize::MAX);
|
|
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.clone(),
|
|
data: b"printf '\\a'\n".to_vec(),
|
|
});
|
|
let bell = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(6),
|
|
|msg| matches!(msg, HubToClient::TerminalTitle { path: p, title } if p == &path && title == "@ bell.term"),
|
|
);
|
|
assert!(bell.is_some(), "did not observe terminal bell title");
|
|
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.clone(),
|
|
data: b":\n".to_vec(),
|
|
});
|
|
let cleared = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(6),
|
|
|msg| matches!(msg, HubToClient::TerminalTitle { path: p, title } if p == &path && title == "bell.term"),
|
|
);
|
|
assert!(
|
|
cleared.is_some(),
|
|
"did not observe terminal title clearing after input"
|
|
);
|
|
|
|
let _ = connection.send(ClientToHub::TerminalClose { path });
|
|
}
|
|
|
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
#[test]
|
|
#[ignore = "manual diagnostic: depends on live shell tty startup timing for SIGWINCH delivery"]
|
|
fn terminal_resize_delivers_sigwinch_with_updated_stty_size() {
|
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join("src")).unwrap();
|
|
fs::write(dir.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
|
|
|
|
let script_path = dir.path().join("resize_sigwinch_test.sh");
|
|
fs::write(
|
|
&script_path,
|
|
r#"#!/bin/sh
|
|
printf '\033[?1049h\033[2J\033[H'
|
|
report_size() {
|
|
size="$(stty size 2>/dev/null || echo 0 0)"
|
|
printf '__SIZE__:%s\n' "$size"
|
|
}
|
|
trap 'report_size' WINCH
|
|
report_size
|
|
while :; do
|
|
sleep 0.2
|
|
done
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let mut perms = fs::metadata(&script_path).unwrap().permissions();
|
|
perms.set_mode(0o755);
|
|
fs::set_permissions(&script_path, perms).unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let path = "repo/.makepad/resize_sigwinch.term".to_string();
|
|
let _ = connection.send(ClientToHub::TerminalOpen {
|
|
path: path.clone(),
|
|
cols: 80,
|
|
rows: 10,
|
|
env: terminal_test_shell_env(dir.path()),
|
|
});
|
|
|
|
let opened = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(4),
|
|
|msg| matches!(msg, HubToClient::TerminalOpened { path: p, .. } if p == &path),
|
|
);
|
|
assert!(opened.is_some(), "did not receive TerminalOpened");
|
|
request_terminal_viewport(&mut connection, &path, 80, 10, usize::MAX);
|
|
assert!(
|
|
wait_for_terminal_shell_ready(&mut connection, &path, Duration::from_secs(8)),
|
|
"terminal shell was not ready before sigwinch resize test"
|
|
);
|
|
|
|
let run_cmd = format!("sh {}\n", script_path.to_string_lossy());
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.clone(),
|
|
data: run_cmd.into_bytes(),
|
|
});
|
|
|
|
assert!(
|
|
wait_for_terminal_frame_contains(
|
|
&connection,
|
|
&path,
|
|
"__SIZE__:10 80",
|
|
Duration::from_secs(8),
|
|
),
|
|
"initial stty size marker was not observed"
|
|
);
|
|
|
|
request_terminal_viewport(&mut connection, &path, 80, 20, usize::MAX);
|
|
assert!(
|
|
wait_for_terminal_frame_contains(
|
|
&connection,
|
|
&path,
|
|
"__SIZE__:20 80",
|
|
Duration::from_secs(8),
|
|
),
|
|
"did not observe stty size update after first resize"
|
|
);
|
|
|
|
request_terminal_viewport(&mut connection, &path, 120, 20, usize::MAX);
|
|
assert!(
|
|
wait_for_terminal_frame_contains(
|
|
&connection,
|
|
&path,
|
|
"__SIZE__:20 120",
|
|
Duration::from_secs(8),
|
|
),
|
|
"did not observe stty size update after second resize"
|
|
);
|
|
|
|
// Stop the loop before closing the PTY session.
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.clone(),
|
|
data: vec![0x03],
|
|
});
|
|
let _ = connection.send(ClientToHub::TerminalClose { path });
|
|
}
|
|
|
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
#[test]
|
|
#[ignore = "manual diagnostic: depends on real bash prompt behavior inside an interactive shell"]
|
|
fn terminal_bash_prompt_sticks_to_bottom_after_grow_resize() {
|
|
let bash_available = std::process::Command::new("sh")
|
|
.args(["-lc", "command -v bash >/dev/null 2>&1"])
|
|
.status()
|
|
.map(|status| status.success())
|
|
.unwrap_or(false);
|
|
if !bash_available {
|
|
eprintln!("skipping: bash binary not found in PATH");
|
|
return;
|
|
}
|
|
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join("src")).unwrap();
|
|
fs::write(dir.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let path = "repo/.makepad/bash_prompt_stick.term".to_string();
|
|
let _ = connection.send(ClientToHub::TerminalOpen {
|
|
path: path.clone(),
|
|
cols: 80,
|
|
rows: 10,
|
|
env: terminal_test_shell_env(dir.path()),
|
|
});
|
|
let opened = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(4),
|
|
|msg| matches!(msg, HubToClient::TerminalOpened { path: p, .. } if p == &path),
|
|
);
|
|
assert!(opened.is_some(), "did not receive TerminalOpened");
|
|
request_terminal_viewport(&mut connection, &path, 80, 10, usize::MAX);
|
|
assert!(
|
|
wait_for_terminal_shell_ready(&mut connection, &path, Duration::from_secs(8)),
|
|
"terminal shell was not ready before bash resize test"
|
|
);
|
|
|
|
// Build deep scrollback and leave the interactive prompt at the bottom.
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.clone(),
|
|
data: b"bash --noprofile --norc -i\nfor i in $(seq 1 180); do echo __SCROLL__$i; done\n"
|
|
.to_vec(),
|
|
});
|
|
|
|
let frame_10 =
|
|
wait_for_terminal_frame_where(&connection, &path, Duration::from_secs(20), |frame| {
|
|
frame.rows == 10
|
|
&& frame.total_lines > 120
|
|
&& framebuffer_to_text(frame).contains("__SCROLL__180")
|
|
})
|
|
.expect("did not observe 10-row bash viewport with deep scrollback");
|
|
assert!(
|
|
frame_10.cursor_visible && frame_10.cursor_row == 9,
|
|
"10-row viewport should have bash cursor on the last row before resize"
|
|
);
|
|
|
|
request_terminal_viewport(&mut connection, &path, 80, 15, usize::MAX);
|
|
|
|
let frame_15 =
|
|
wait_for_terminal_frame_where(&connection, &path, Duration::from_secs(10), |frame| {
|
|
frame.rows == 15
|
|
&& frame.total_lines >= frame_10.total_lines
|
|
&& framebuffer_to_text(frame).contains("__SCROLL__180")
|
|
})
|
|
.expect("did not observe 15-row resized viewport");
|
|
assert!(
|
|
frame_15.cursor_visible && frame_15.cursor_row == 14,
|
|
"15-row viewport should keep bash cursor/prompt pinned to bottom"
|
|
);
|
|
assert!(
|
|
frame_15.top_row <= frame_10.top_row,
|
|
"grow resize should keep bottom content anchored (top_row must not increase)"
|
|
);
|
|
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.clone(),
|
|
data: b"exit\n".to_vec(),
|
|
});
|
|
let _ = connection.send(ClientToHub::TerminalClose { path });
|
|
}
|
|
|
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
#[test]
|
|
fn terminal_bash_grow_resize_clamps_to_top_when_history_is_insufficient() {
|
|
let bash_available = std::process::Command::new("sh")
|
|
.args(["-lc", "command -v bash >/dev/null 2>&1"])
|
|
.status()
|
|
.map(|status| status.success())
|
|
.unwrap_or(false);
|
|
if !bash_available {
|
|
eprintln!("skipping: bash binary not found in PATH");
|
|
return;
|
|
}
|
|
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join("src")).unwrap();
|
|
fs::write(dir.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let path = "repo/.makepad/bash_resize_clamp_top.term".to_string();
|
|
let _ = connection.send(ClientToHub::TerminalOpen {
|
|
path: path.clone(),
|
|
cols: 80,
|
|
rows: 10,
|
|
env: terminal_test_shell_env(dir.path()),
|
|
});
|
|
let opened = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(4),
|
|
|msg| matches!(msg, HubToClient::TerminalOpened { path: p, .. } if p == &path),
|
|
);
|
|
assert!(opened.is_some(), "did not receive TerminalOpened");
|
|
request_terminal_viewport(&mut connection, &path, 80, 10, usize::MAX);
|
|
|
|
// Fresh shell has insufficient history to fill additional rows on grow.
|
|
let frame_10 =
|
|
wait_for_terminal_frame_where(&connection, &path, Duration::from_secs(10), |frame| {
|
|
frame.rows == 10
|
|
})
|
|
.expect("did not observe short-history 10-row frame");
|
|
|
|
request_terminal_viewport(&mut connection, &path, 80, 15, usize::MAX);
|
|
|
|
let frame_15 =
|
|
wait_for_terminal_frame_where(&connection, &path, Duration::from_secs(10), |frame| {
|
|
frame.rows == 15
|
|
})
|
|
.expect("did not observe short-history 15-row frame");
|
|
|
|
assert!(
|
|
frame_10.top_row == 0,
|
|
"short-history baseline should already be clamped to top"
|
|
);
|
|
assert!(
|
|
frame_15.top_row == 0,
|
|
"grow resize with insufficient history must clamp to top (top_row == 0)"
|
|
);
|
|
assert!(
|
|
frame_15.cursor_row < 14,
|
|
"with insufficient history, cursor/prompt should not be forced to bottom row"
|
|
);
|
|
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.clone(),
|
|
data: b"exit\n".to_vec(),
|
|
});
|
|
let _ = connection.send(ClientToHub::TerminalClose { path });
|
|
}
|
|
|
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
#[test]
|
|
fn terminal_codex_prompt_sticks_to_bottom_after_resize() {
|
|
let codex_available = std::process::Command::new("sh")
|
|
.args(["-lc", "command -v codex >/dev/null 2>&1"])
|
|
.status()
|
|
.map(|status| status.success())
|
|
.unwrap_or(false);
|
|
if !codex_available {
|
|
eprintln!("skipping: codex binary not found in PATH");
|
|
return;
|
|
}
|
|
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join("src")).unwrap();
|
|
fs::write(dir.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let path = "repo/.makepad/codex_prompt_stick.term".to_string();
|
|
let _ = connection.send(ClientToHub::TerminalOpen {
|
|
path: path.clone(),
|
|
cols: 80,
|
|
rows: 10,
|
|
env: terminal_test_shell_env(dir.path()),
|
|
});
|
|
let opened = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(4),
|
|
|msg| matches!(msg, HubToClient::TerminalOpened { path: p, .. } if p == &path),
|
|
);
|
|
assert!(opened.is_some(), "did not receive TerminalOpened");
|
|
|
|
request_terminal_viewport(&mut connection, &path, 80, 10, usize::MAX);
|
|
|
|
// Build scrollback first, then start codex.
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.clone(),
|
|
data: b"for i in $(seq 1 220); do echo __SCROLL__$i; done\ncodex\n".to_vec(),
|
|
});
|
|
|
|
let frame_10 =
|
|
wait_for_terminal_frame_where(&connection, &path, Duration::from_secs(30), |frame| {
|
|
frame.rows == 10
|
|
&& frame.total_lines > 60
|
|
&& !framebuffer_last_row_text(frame).trim().is_empty()
|
|
})
|
|
.expect("did not observe 10-row codex viewport state");
|
|
|
|
request_terminal_viewport(&mut connection, &path, 80, 15, usize::MAX);
|
|
|
|
let frame_15 =
|
|
wait_for_terminal_frame_where(&connection, &path, Duration::from_secs(20), |frame| {
|
|
frame.rows == 15
|
|
&& frame.total_lines >= frame_10.total_lines
|
|
&& !framebuffer_last_row_text(frame).trim().is_empty()
|
|
})
|
|
.expect("did not observe 15-row codex viewport state");
|
|
|
|
assert!(
|
|
!framebuffer_last_row_text(&frame_15).trim().is_empty(),
|
|
"expected codex prompt/cursor to stay at bottom after resize (10 -> 15 rows)"
|
|
);
|
|
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.clone(),
|
|
data: vec![0x03],
|
|
});
|
|
let _ = connection.send(ClientToHub::TerminalClose { path });
|
|
}
|
|
|
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
#[test]
|
|
#[ignore = "manual diagnostic: real codex UI is nondeterministic; deterministic resize coverage lives in terminal_core and dispatch unit tests"]
|
|
fn terminal_codex_fast_resize_roundtrip_preserves_top_and_bottom_rows() {
|
|
let codex_available = std::process::Command::new("sh")
|
|
.args(["-lc", "command -v codex >/dev/null 2>&1"])
|
|
.status()
|
|
.map(|status| status.success())
|
|
.unwrap_or(false);
|
|
if !codex_available {
|
|
eprintln!("skipping: codex binary not found in PATH");
|
|
return;
|
|
}
|
|
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join("src")).unwrap();
|
|
fs::write(dir.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let path = "repo/.makepad/codex_fast_resize.term".to_string();
|
|
let _ = connection.send(ClientToHub::TerminalOpen {
|
|
path: path.clone(),
|
|
cols: 80,
|
|
rows: 20,
|
|
env: terminal_test_shell_env(dir.path()),
|
|
});
|
|
let opened = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(5),
|
|
|msg| matches!(msg, HubToClient::TerminalOpened { path: p, .. } if p == &path),
|
|
);
|
|
assert!(opened.is_some(), "did not receive TerminalOpened");
|
|
|
|
request_terminal_viewport(&mut connection, &path, 80, 20, usize::MAX);
|
|
assert!(
|
|
wait_for_terminal_shell_ready(&mut connection, &path, Duration::from_secs(8)),
|
|
"terminal shell was not ready before codex resize roundtrip test"
|
|
);
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.clone(),
|
|
data: b"for i in $(seq 1 260); do echo __SCROLL__$i; done\ncodex\n".to_vec(),
|
|
});
|
|
|
|
let baseline =
|
|
wait_for_terminal_frame_where(&connection, &path, Duration::from_secs(35), |frame| {
|
|
frame.rows == 20
|
|
&& frame.total_lines > 80
|
|
&& !framebuffer_last_row_text(frame).trim().is_empty()
|
|
})
|
|
.expect("did not observe baseline 20-row codex frame");
|
|
|
|
let baseline_top = framebuffer_row_text(&baseline, 0);
|
|
let baseline_sep = framebuffer_row_text(&baseline, 1);
|
|
let baseline_bottom = framebuffer_last_row_text(&baseline);
|
|
let baseline_text = framebuffer_to_text(&baseline);
|
|
|
|
// Repeat rapid resize bursts to catch fast-drag race behavior.
|
|
for cycle in 0..5 {
|
|
for rows in 21..=40 {
|
|
request_terminal_viewport(&mut connection, &path, 80, rows, usize::MAX);
|
|
}
|
|
for rows in (20..40).rev() {
|
|
request_terminal_viewport(&mut connection, &path, 80, rows, usize::MAX);
|
|
}
|
|
request_terminal_viewport(&mut connection, &path, 80, 20, usize::MAX);
|
|
|
|
let final_frame =
|
|
wait_for_terminal_frame_where(&connection, &path, Duration::from_secs(8), |frame| {
|
|
frame.rows == 20 && !framebuffer_last_row_text(frame).trim().is_empty()
|
|
})
|
|
.expect("did not observe final 20-row codex frame after fast resize burst");
|
|
|
|
let final_top = framebuffer_row_text(&final_frame, 0);
|
|
let final_sep = framebuffer_row_text(&final_frame, 1);
|
|
let final_bottom = framebuffer_last_row_text(&final_frame);
|
|
|
|
assert_eq!(
|
|
final_top.trim(),
|
|
baseline_top.trim(),
|
|
"top row changed after fast resize roundtrip cycle {}",
|
|
cycle
|
|
);
|
|
assert_eq!(
|
|
final_sep.trim(),
|
|
baseline_sep.trim(),
|
|
"separator row changed after fast resize roundtrip cycle {}",
|
|
cycle
|
|
);
|
|
assert_eq!(
|
|
final_bottom.trim(),
|
|
baseline_bottom.trim(),
|
|
"bottom row changed after fast resize roundtrip cycle {}",
|
|
cycle
|
|
);
|
|
assert_eq!(
|
|
framebuffer_to_text(&final_frame),
|
|
baseline_text,
|
|
"full framebuffer changed after fast resize roundtrip cycle {}",
|
|
cycle
|
|
);
|
|
}
|
|
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.clone(),
|
|
data: vec![0x03],
|
|
});
|
|
let _ = connection.send(ClientToHub::TerminalClose { path });
|
|
}
|
|
|
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
#[test]
|
|
#[ignore = "manual diagnostic: real codex UI is nondeterministic; deterministic resize coverage lives in terminal_core and dispatch unit tests"]
|
|
fn terminal_codex_fast_vs_slow_wiggle_same_final_frame() {
|
|
let codex_available = std::process::Command::new("sh")
|
|
.args(["-lc", "command -v codex >/dev/null 2>&1"])
|
|
.status()
|
|
.map(|status| status.success())
|
|
.unwrap_or(false);
|
|
if !codex_available {
|
|
eprintln!("skipping: codex binary not found in PATH");
|
|
return;
|
|
}
|
|
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join("src")).unwrap();
|
|
fs::write(dir.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let wiggle: &[u16] = &[30, 12, 28, 10, 26, 14, 29, 11, 27, 13, 30, 20];
|
|
|
|
let mut run_mode = |path: &str, slow: bool| -> String {
|
|
let tui_log_path = format!("/tmp/{}.{}.log", path.replace('/', "_"), std::process::id());
|
|
let mut env = terminal_test_shell_env(dir.path());
|
|
env.insert("TUI_LOG".to_string(), tui_log_path.clone());
|
|
let _ = connection.send(ClientToHub::TerminalOpen {
|
|
path: path.to_string(),
|
|
cols: 80,
|
|
rows: 20,
|
|
env,
|
|
});
|
|
let opened = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(5),
|
|
|msg| matches!(msg, HubToClient::TerminalOpened { path: p, .. } if p == path),
|
|
);
|
|
assert!(
|
|
opened.is_some(),
|
|
"did not receive TerminalOpened for {}",
|
|
path
|
|
);
|
|
|
|
request_terminal_viewport(&mut connection, path, 80, 20, usize::MAX);
|
|
assert!(
|
|
wait_for_terminal_shell_ready(&mut connection, path, Duration::from_secs(8)),
|
|
"terminal shell was not ready before codex wiggle test for {}",
|
|
path
|
|
);
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.to_string(),
|
|
data: b"for i in $(seq 1 260); do echo __SCROLL__$i; done\ncodex\n".to_vec(),
|
|
});
|
|
let _baseline =
|
|
wait_for_terminal_frame_where(&connection, path, Duration::from_secs(35), |frame| {
|
|
frame.rows == 20
|
|
&& frame.total_lines > 80
|
|
&& !framebuffer_last_row_text(frame).trim().is_empty()
|
|
})
|
|
.expect("did not observe baseline codex frame");
|
|
|
|
if slow {
|
|
for rows in wiggle {
|
|
request_terminal_viewport(&mut connection, path, 80, *rows, usize::MAX);
|
|
let _ = wait_for_terminal_frame_where(
|
|
&connection,
|
|
path,
|
|
Duration::from_secs(6),
|
|
|frame| {
|
|
frame.rows == *rows && !framebuffer_last_row_text(frame).trim().is_empty()
|
|
},
|
|
)
|
|
.expect("did not observe slow wiggle step frame");
|
|
}
|
|
} else {
|
|
for rows in wiggle {
|
|
request_terminal_viewport(&mut connection, path, 80, *rows, usize::MAX);
|
|
}
|
|
}
|
|
|
|
request_terminal_viewport(&mut connection, path, 80, 20, usize::MAX);
|
|
let final_frame =
|
|
wait_for_terminal_frame_where(&connection, path, Duration::from_secs(10), |frame| {
|
|
frame.rows == 20 && !framebuffer_last_row_text(frame).trim().is_empty()
|
|
})
|
|
.expect("did not observe final 20-row frame");
|
|
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.to_string(),
|
|
data: vec![0x03],
|
|
});
|
|
let _ = connection.send(ClientToHub::TerminalClose {
|
|
path: path.to_string(),
|
|
});
|
|
framebuffer_to_text(&final_frame)
|
|
};
|
|
|
|
let slow_text = run_mode("repo/.makepad/codex_slow_wiggle.term", true);
|
|
let fast_text = run_mode("repo/.makepad/codex_fast_wiggle.term", false);
|
|
assert_eq!(
|
|
fast_text, slow_text,
|
|
"fast wiggle final frame diverged from slow wiggle final frame"
|
|
);
|
|
}
|
|
|
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
#[test]
|
|
fn terminal_makepad_tui_fast_wiggle_preserves_framebuffer() {
|
|
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.parent()
|
|
.and_then(|p| p.parent())
|
|
.expect("workspace root");
|
|
let tui_bin = workspace_root.join("target/release/makepad-tui-test");
|
|
if !tui_bin.exists() {
|
|
eprintln!(
|
|
"skipping: makepad-tui-test binary not found at {}",
|
|
tui_bin.display()
|
|
);
|
|
return;
|
|
}
|
|
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join("src")).unwrap();
|
|
fs::write(dir.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let path = "repo/.makepad/tui_fast_wiggle.term".to_string();
|
|
let _ = connection.send(ClientToHub::TerminalOpen {
|
|
path: path.clone(),
|
|
cols: 80,
|
|
rows: 20,
|
|
env: terminal_test_shell_env(dir.path()),
|
|
});
|
|
let opened = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(5),
|
|
|msg| matches!(msg, HubToClient::TerminalOpened { path: p, .. } if p == &path),
|
|
);
|
|
assert!(opened.is_some(), "did not receive TerminalOpened");
|
|
|
|
request_terminal_viewport(&mut connection, &path, 80, 20, usize::MAX);
|
|
|
|
let launch = format!("{}\n", tui_bin.display());
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.clone(),
|
|
data: launch.into_bytes(),
|
|
});
|
|
|
|
let baseline =
|
|
wait_for_terminal_frame_where(&connection, &path, Duration::from_secs(20), |frame| {
|
|
frame.rows == 20
|
|
&& framebuffer_to_text(frame).contains("TUI Test")
|
|
&& framebuffer_to_text(frame).contains("directory:")
|
|
})
|
|
.expect("did not observe baseline makepad-tui frame");
|
|
let baseline_text = framebuffer_to_text(&baseline);
|
|
|
|
// Rapid wiggle with jumps between 10..30 rows, repeated to amplify races.
|
|
let jumps: &[u16] = &[30, 12, 28, 10, 26, 14, 29, 11, 27, 13, 30, 20];
|
|
for _ in 0..6 {
|
|
for rows in jumps {
|
|
request_terminal_viewport(&mut connection, &path, 80, *rows, usize::MAX);
|
|
}
|
|
}
|
|
request_terminal_viewport(&mut connection, &path, 80, 20, usize::MAX);
|
|
|
|
let final_frame =
|
|
wait_for_terminal_frame_where(&connection, &path, Duration::from_secs(10), |frame| {
|
|
frame.rows == 20 && framebuffer_to_text(frame).contains("TUI Test")
|
|
})
|
|
.expect("did not observe final 20-row makepad-tui frame");
|
|
let final_text = framebuffer_to_text(&final_frame);
|
|
|
|
assert_eq!(
|
|
final_text, baseline_text,
|
|
"makepad-tui framebuffer changed after fast wiggle resize burst"
|
|
);
|
|
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.clone(),
|
|
data: vec![0x03],
|
|
});
|
|
let _ = connection.send(ClientToHub::TerminalClose { path });
|
|
}
|
|
|
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
#[test]
|
|
#[ignore = "stress diagnostic: intermittently reproduces fast-resize garbling"]
|
|
fn terminal_makepad_tui_fast_resize_during_output_matches_no_resize() {
|
|
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.parent()
|
|
.and_then(|p| p.parent())
|
|
.expect("workspace root");
|
|
let tui_bin = workspace_root.join("target/release/makepad-tui-test");
|
|
if !tui_bin.exists() {
|
|
eprintln!(
|
|
"skipping: makepad-tui-test binary not found at {}",
|
|
tui_bin.display()
|
|
);
|
|
return;
|
|
}
|
|
|
|
let short_mount = std::path::PathBuf::from("/tmp").join(format!(
|
|
"mpt_{}_{}",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_millis()
|
|
));
|
|
let _ = fs::remove_dir_all(&short_mount);
|
|
fs::create_dir_all(short_mount.join("src")).unwrap();
|
|
fs::write(short_mount.join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: short_mount.clone(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let mut run_mode = |path: &str, fast_resize: bool| -> String {
|
|
let tui_log_path = format!("/tmp/{}.{}.log", path.replace('/', "_"), std::process::id());
|
|
let mut env = std::collections::HashMap::new();
|
|
env.extend(terminal_test_shell_env(short_mount.as_path()));
|
|
env.insert("TUI_LOG".to_string(), tui_log_path.clone());
|
|
let _ = connection.send(ClientToHub::TerminalOpen {
|
|
path: path.to_string(),
|
|
cols: 80,
|
|
rows: 20,
|
|
env,
|
|
});
|
|
let opened = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(5),
|
|
|msg| matches!(msg, HubToClient::TerminalOpened { path: p, .. } if p == path),
|
|
);
|
|
assert!(
|
|
opened.is_some(),
|
|
"did not receive TerminalOpened for {}",
|
|
path
|
|
);
|
|
|
|
request_terminal_viewport(&mut connection, path, 80, 20, usize::MAX);
|
|
let launch = format!("{}\n", tui_bin.display());
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.to_string(),
|
|
data: launch.into_bytes(),
|
|
});
|
|
|
|
let _baseline =
|
|
wait_for_terminal_frame_where(&connection, path, Duration::from_secs(20), |frame| {
|
|
frame.rows == 20
|
|
&& framebuffer_to_text(frame).contains("TUI Test")
|
|
&& framebuffer_to_text(frame).contains("directory:")
|
|
})
|
|
.expect("did not observe baseline makepad-tui frame");
|
|
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.to_string(),
|
|
data: b"hi\r".to_vec(),
|
|
});
|
|
|
|
// 10 cycles of multi-line jump scales between 10 and 40 rows.
|
|
let jumps: &[u16] = &[40, 10, 38, 12, 36, 14, 34, 16, 32, 18, 30, 20];
|
|
for _ in 0..10 {
|
|
for rows in jumps {
|
|
request_terminal_viewport(&mut connection, path, 80, *rows, usize::MAX);
|
|
if !fast_resize {
|
|
let _ = wait_for_terminal_frame_where(
|
|
&connection,
|
|
path,
|
|
Duration::from_secs(2),
|
|
|frame| frame.rows == *rows,
|
|
)
|
|
.expect("did not observe slow resize step frame");
|
|
}
|
|
}
|
|
}
|
|
|
|
request_terminal_viewport(&mut connection, path, 80, 20, usize::MAX);
|
|
let deadline = Instant::now() + Duration::from_secs(12);
|
|
let mut next_poll = Instant::now();
|
|
let mut final_text = None;
|
|
let mut last_20_text = String::new();
|
|
while Instant::now() < deadline {
|
|
if Instant::now() >= next_poll {
|
|
request_terminal_viewport(&mut connection, path, 80, 20, usize::MAX);
|
|
next_poll = Instant::now() + Duration::from_millis(200);
|
|
}
|
|
let Some(msg) = connection.recv_timeout(Duration::from_millis(100)) else {
|
|
continue;
|
|
};
|
|
let HubToClient::TerminalFramebuffer {
|
|
path: output_path,
|
|
frame,
|
|
} = msg
|
|
else {
|
|
continue;
|
|
};
|
|
if output_path != path {
|
|
continue;
|
|
}
|
|
if frame.rows != 20 {
|
|
continue;
|
|
}
|
|
let text = framebuffer_to_text(&frame);
|
|
last_20_text = text.clone();
|
|
if text.contains("[10/10] hi")
|
|
&& text.contains("Enter a prompt...")
|
|
&& text.contains("fake-model")
|
|
&& !text.contains("Working (")
|
|
{
|
|
final_text = Some(text);
|
|
break;
|
|
}
|
|
}
|
|
let _completed_text = final_text.unwrap_or_else(|| {
|
|
let log_tail = fs::read_to_string(&tui_log_path)
|
|
.map(|s| tail_lines(&s, 40))
|
|
.unwrap_or_else(|_| "<missing TUI_LOG>".to_string());
|
|
panic!(
|
|
"did not observe completed 20-row frame after resize burst; last 20-row frame:\n{}\n\nTUI_LOG tail:\n{}",
|
|
last_20_text, log_tail
|
|
)
|
|
});
|
|
// Force a deterministic post-resize redraw cycle before comparing.
|
|
request_terminal_viewport(&mut connection, path, 80, 21, usize::MAX);
|
|
let _ = wait_for_terminal_frame_where(&connection, path, Duration::from_secs(3), |frame| {
|
|
frame.rows == 21
|
|
})
|
|
.expect("did not observe settle 21-row frame");
|
|
request_terminal_viewport(&mut connection, path, 80, 20, usize::MAX);
|
|
let settled =
|
|
wait_for_terminal_frame_where(&connection, path, Duration::from_secs(3), |frame| {
|
|
frame.rows == 20
|
|
})
|
|
.expect("did not observe settle 20-row frame");
|
|
let final_text = framebuffer_to_text(&settled);
|
|
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.to_string(),
|
|
data: vec![0x03],
|
|
});
|
|
let _ = connection.send(ClientToHub::TerminalClose {
|
|
path: path.to_string(),
|
|
});
|
|
final_text
|
|
};
|
|
|
|
let slow_text = run_mode("repo/.makepad/tui_slow_resize_during_output.term", false);
|
|
let fast_text = run_mode("repo/.makepad/tui_fast_resize_during_output.term", true);
|
|
let _ = fs::remove_dir_all(&short_mount);
|
|
assert_eq!(
|
|
fast_text, slow_text,
|
|
"fast resize during output diverged from slow resize reference"
|
|
);
|
|
}
|
|
|
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
#[test]
|
|
#[ignore = "stress diagnostic: reproduces persistent corruption after fast resize"]
|
|
fn terminal_makepad_tui_fast_then_slow_same_session_matches_slow_only() {
|
|
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.parent()
|
|
.and_then(|p| p.parent())
|
|
.expect("workspace root");
|
|
let tui_bin = workspace_root.join("target/release/makepad-tui-test");
|
|
if !tui_bin.exists() {
|
|
eprintln!(
|
|
"skipping: makepad-tui-test binary not found at {}",
|
|
tui_bin.display()
|
|
);
|
|
return;
|
|
}
|
|
|
|
let mount = std::path::PathBuf::from("/tmp").join(format!(
|
|
"mpp_{}_{}",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_millis()
|
|
));
|
|
let _ = fs::remove_dir_all(&mount);
|
|
fs::create_dir_all(mount.join("src")).unwrap();
|
|
fs::write(mount.join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: mount.clone(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let jumps: &[u16] = &[40, 10, 38, 12, 36, 14, 34, 16, 32, 18, 30, 20];
|
|
|
|
let run_session = |connection: &mut makepad_studio_hub::HubConnection,
|
|
path: &str,
|
|
do_fast_first: bool|
|
|
-> String {
|
|
let tui_log_path = format!("/tmp/{}.{}.log", path.replace('/', "_"), std::process::id());
|
|
let mut env = std::collections::HashMap::new();
|
|
env.extend(terminal_test_shell_env(mount.as_path()));
|
|
env.insert("TUI_LOG".to_string(), tui_log_path.clone());
|
|
let _ = connection.send(ClientToHub::TerminalOpen {
|
|
path: path.to_string(),
|
|
cols: 80,
|
|
rows: 20,
|
|
env,
|
|
});
|
|
let opened = wait_for_message(
|
|
connection,
|
|
Duration::from_secs(5),
|
|
|msg| matches!(msg, HubToClient::TerminalOpened { path: p, .. } if p == path),
|
|
);
|
|
assert!(
|
|
opened.is_some(),
|
|
"did not receive TerminalOpened for {}",
|
|
path
|
|
);
|
|
|
|
request_terminal_viewport(connection, path, 80, 20, usize::MAX);
|
|
let launch = format!("{}\n", tui_bin.display());
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.to_string(),
|
|
data: launch.into_bytes(),
|
|
});
|
|
let _baseline =
|
|
wait_for_terminal_frame_where(connection, path, Duration::from_secs(20), |frame| {
|
|
frame.rows == 20
|
|
&& framebuffer_to_text(frame).contains("TUI Test")
|
|
&& framebuffer_to_text(frame).contains("directory:")
|
|
})
|
|
.expect("did not observe baseline makepad-tui frame");
|
|
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.to_string(),
|
|
data: b"hi\r".to_vec(),
|
|
});
|
|
|
|
if do_fast_first {
|
|
for _ in 0..10 {
|
|
for rows in jumps {
|
|
request_terminal_viewport(connection, path, 80, *rows, usize::MAX);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Slow pass after potential corruption.
|
|
for _ in 0..2 {
|
|
for rows in jumps {
|
|
request_terminal_viewport(connection, path, 80, *rows, usize::MAX);
|
|
let _ = wait_for_terminal_frame_where(
|
|
connection,
|
|
path,
|
|
Duration::from_secs(2),
|
|
|frame| frame.rows == *rows,
|
|
)
|
|
.expect("did not observe slow resize step frame");
|
|
}
|
|
}
|
|
|
|
request_terminal_viewport(connection, path, 80, 20, usize::MAX);
|
|
let deadline = Instant::now() + Duration::from_secs(12);
|
|
let mut final_text = None;
|
|
let mut last_text = String::new();
|
|
while Instant::now() < deadline {
|
|
request_terminal_viewport(connection, path, 80, 20, usize::MAX);
|
|
let Some(msg) = connection.recv_timeout(Duration::from_millis(120)) else {
|
|
continue;
|
|
};
|
|
let HubToClient::TerminalFramebuffer {
|
|
path: output_path,
|
|
frame,
|
|
} = msg
|
|
else {
|
|
continue;
|
|
};
|
|
if output_path != path || frame.rows != 20 {
|
|
continue;
|
|
}
|
|
let text = framebuffer_to_text(&frame);
|
|
last_text = text.clone();
|
|
if text.contains("[10/10] hi")
|
|
&& text.contains("Enter a prompt...")
|
|
&& text.contains("fake-model")
|
|
&& !text.contains("Working (")
|
|
{
|
|
final_text = Some(text);
|
|
break;
|
|
}
|
|
}
|
|
let _completed_text = final_text.unwrap_or_else(|| {
|
|
let log_tail = fs::read_to_string(&tui_log_path)
|
|
.map(|s| tail_lines(&s, 40))
|
|
.unwrap_or_else(|_| "<missing TUI_LOG>".to_string());
|
|
panic!(
|
|
"did not observe completed frame:\n{}\n\nTUI_LOG tail:\n{}",
|
|
last_text, log_tail
|
|
)
|
|
});
|
|
request_terminal_viewport(connection, path, 80, 21, usize::MAX);
|
|
let _ = wait_for_terminal_frame_where(connection, path, Duration::from_secs(3), |frame| {
|
|
frame.rows == 21
|
|
})
|
|
.expect("did not observe settle 21-row frame");
|
|
request_terminal_viewport(connection, path, 80, 20, usize::MAX);
|
|
let settled =
|
|
wait_for_terminal_frame_where(connection, path, Duration::from_secs(3), |frame| {
|
|
frame.rows == 20
|
|
})
|
|
.expect("did not observe settle 20-row frame");
|
|
let final_text = framebuffer_to_text(&settled);
|
|
|
|
let _ = connection.send(ClientToHub::TerminalInput {
|
|
path: path.to_string(),
|
|
data: vec![0x03],
|
|
});
|
|
let _ = connection.send(ClientToHub::TerminalClose {
|
|
path: path.to_string(),
|
|
});
|
|
final_text
|
|
};
|
|
|
|
let slow_only = run_session(
|
|
&mut connection,
|
|
"repo/.makepad/tui_slow_only_after_input.term",
|
|
false,
|
|
);
|
|
let fast_then_slow = run_session(
|
|
&mut connection,
|
|
"repo/.makepad/tui_fast_then_slow_after_input.term",
|
|
true,
|
|
);
|
|
let _ = fs::remove_dir_all(&mount);
|
|
|
|
assert_eq!(
|
|
fast_then_slow, slow_only,
|
|
"fast-first corruption persisted into subsequent slow resize"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn file_tree_keeps_hidden_directories_for_backend() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join("src")).unwrap();
|
|
fs::create_dir_all(dir.path().join(".hidden")).unwrap();
|
|
fs::write(dir.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
|
|
fs::write(dir.path().join(".hidden/secret.txt"), "secret\n").unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let _ = connection.send(ClientToHub::LoadFileTree {
|
|
mount: "repo".to_string(),
|
|
});
|
|
let tree = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(3),
|
|
|msg| matches!(msg, HubToClient::FileTree { mount, .. } if mount == "repo"),
|
|
)
|
|
.expect("did not receive file tree");
|
|
|
|
match tree {
|
|
HubToClient::FileTree { data, .. } => {
|
|
assert!(
|
|
data.nodes.iter().any(|node| node.path == "repo/.hidden"),
|
|
"hidden directory should be present in backend file tree"
|
|
);
|
|
assert!(
|
|
data.nodes
|
|
.iter()
|
|
.any(|node| node.path == "repo/.hidden/secret.txt"),
|
|
"hidden file should be present in backend file tree"
|
|
);
|
|
assert!(
|
|
data.nodes.iter().any(|node| node.path == "repo/src/lib.rs"),
|
|
"expected visible file to remain in file tree"
|
|
);
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unmount_emits_file_tree_diff_scoped_to_mount() {
|
|
let mount_a = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
let mount_b = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(mount_a.path().join("src")).unwrap();
|
|
fs::create_dir_all(mount_b.path().join("src")).unwrap();
|
|
fs::write(mount_a.path().join("src/a.rs"), "pub fn a() {}\n").unwrap();
|
|
fs::write(mount_b.path().join("src/b.rs"), "pub fn b() {}\n").unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![
|
|
MountConfig {
|
|
name: "alpha".to_string(),
|
|
path: mount_a.path().to_path_buf(),
|
|
},
|
|
MountConfig {
|
|
name: "beta".to_string(),
|
|
path: mount_b.path().to_path_buf(),
|
|
},
|
|
],
|
|
..Default::default()
|
|
};
|
|
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
let _ = connection.send(ClientToHub::Unmount {
|
|
name: "alpha".to_string(),
|
|
});
|
|
|
|
let diff = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(3),
|
|
|msg| matches!(msg, HubToClient::FileTreeDiff { mount, .. } if mount == "alpha"),
|
|
)
|
|
.expect("did not receive alpha FileTreeDiff");
|
|
|
|
match diff {
|
|
HubToClient::FileTreeDiff { mount, changes } => {
|
|
assert_eq!(mount, "alpha");
|
|
assert!(!changes.is_empty(), "expected removed paths for alpha");
|
|
for change in changes {
|
|
match change {
|
|
makepad_studio_protocol::hub_protocol::FileTreeChange::Added {
|
|
path, ..
|
|
}
|
|
| makepad_studio_protocol::hub_protocol::FileTreeChange::Removed { path }
|
|
| makepad_studio_protocol::hub_protocol::FileTreeChange::Modified {
|
|
path,
|
|
..
|
|
} => {
|
|
assert!(path == "alpha" || path.starts_with("alpha/"));
|
|
assert!(!path.starts_with("beta/"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn run_items_are_pushed_per_mount() {
|
|
let mount_a = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
let mount_b = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
|
|
fs::write(
|
|
mount_a.path().join("makepad.splash"),
|
|
"use mod.hub\nhub.set_run_items([{name:\"alpha-app\" in_studio:true on_run:fn(){}}])\n",
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
mount_b.path().join("makepad.splash"),
|
|
"use mod.hub\nhub.set_run_items([{name:\"beta-app\" in_studio:true on_run:fn(){}}])\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![
|
|
MountConfig {
|
|
name: "alpha".to_string(),
|
|
path: mount_a.path().to_path_buf(),
|
|
},
|
|
MountConfig {
|
|
name: "beta".to_string(),
|
|
path: mount_b.path().to_path_buf(),
|
|
},
|
|
],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let _ = connection.send(ClientToHub::ObserveMount {
|
|
mount: "alpha".to_string(),
|
|
primary: Some(true),
|
|
});
|
|
let alpha = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(5),
|
|
|msg| matches!(msg, HubToClient::RunItems { mount, items } if mount == "alpha" && items.len() == 1),
|
|
)
|
|
.expect("did not receive alpha run items");
|
|
match alpha {
|
|
HubToClient::RunItems { mount, items } => {
|
|
assert_eq!(mount, "alpha");
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0].name, "alpha-app");
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
|
|
let _ = connection.send(ClientToHub::ObserveMount {
|
|
mount: "beta".to_string(),
|
|
primary: Some(true),
|
|
});
|
|
let beta = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(5),
|
|
|msg| matches!(msg, HubToClient::RunItems { mount, items } if mount == "beta" && items.len() == 1),
|
|
)
|
|
.expect("did not receive beta run items");
|
|
match beta {
|
|
HubToClient::RunItems { mount, items } => {
|
|
assert_eq!(mount, "beta");
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0].name, "beta-app");
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn run_item_executes_named_on_run_callback() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::write(
|
|
dir.path().join("makepad.splash"),
|
|
"use mod.std\nuse mod.hub\nhub.set_run_items([{name:\"hello\" in_studio:true on_run:fn(){std.println(\"hello from item\")}}])\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let _ = connection.send(ClientToHub::ObserveMount {
|
|
mount: "repo".to_string(),
|
|
primary: Some(true),
|
|
});
|
|
let run_items = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(3),
|
|
|msg| matches!(msg, HubToClient::RunItems { mount, items } if mount == "repo" && items.len() == 1),
|
|
)
|
|
.expect("did not receive RunItems");
|
|
match run_items {
|
|
HubToClient::RunItems { items, .. } => {
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0].name, "hello");
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
|
|
let _ = connection.send(ClientToHub::RunItem {
|
|
mount: "repo".to_string(),
|
|
name: "hello".to_string(),
|
|
});
|
|
let _ = drain_messages(&connection, Duration::from_millis(250));
|
|
|
|
let query_id = connection.send(ClientToHub::QueryLogs {
|
|
build_id: None,
|
|
level: None,
|
|
source: None,
|
|
file: None,
|
|
pattern: Some("hello from item".to_string()),
|
|
is_regex: None,
|
|
since_index: None,
|
|
live: Some(false),
|
|
});
|
|
let log_results = wait_for_message(&connection, Duration::from_secs(3), |msg| {
|
|
matches!(
|
|
msg,
|
|
HubToClient::QueryLogResults {
|
|
query_id: id, ..
|
|
} if *id == query_id
|
|
)
|
|
})
|
|
.expect("did not receive QueryLogResults");
|
|
|
|
match log_results {
|
|
HubToClient::QueryLogResults { entries, done, .. } => {
|
|
assert!(done);
|
|
let messages: Vec<String> = entries
|
|
.iter()
|
|
.map(|entry| entry.1.message.clone())
|
|
.collect();
|
|
assert!(
|
|
entries
|
|
.iter()
|
|
.any(|entry| entry.1.message.contains("hello from item")),
|
|
"expected run item log in splash logs, got {:?}",
|
|
messages
|
|
);
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn run_item_spawns_cargo_run_for_clicked_name() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join("src")).unwrap();
|
|
fs::write(
|
|
dir.path().join("Cargo.toml"),
|
|
"[package]\nname = \"makepad-example-splash\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
dir.path().join("src/main.rs"),
|
|
"fn main() {\n println!(\"hello from clicked item\");\n}\n",
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
dir.path().join("makepad.splash"),
|
|
"use mod.hub\nhub.set_run_items([{name:\"makepad-example-splash\" in_studio:true on_run:fn(id){let name = self.name hub.run({\"STUDIO_HOST\":hub.studio_ip \"STUDIO_BUILD\":id.to_string() \"STUDIO_CRATE\":name}, \"cargo\", [\"run\" \"-p\" name \"--release\" \"--message-format=json\" \"--\" \"--message-format=json\" \"--stdin-loop\"])}}])\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let _ = connection.send(ClientToHub::ObserveMount {
|
|
mount: "repo".to_string(),
|
|
primary: Some(true),
|
|
});
|
|
let run_items = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(3),
|
|
|msg| matches!(msg, HubToClient::RunItems { mount, items } if mount == "repo" && items.len() == 1),
|
|
)
|
|
.expect("did not receive RunItems");
|
|
match run_items {
|
|
HubToClient::RunItems { items, .. } => {
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0].name, "makepad-example-splash");
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
|
|
let _ = connection.send(ClientToHub::RunItem {
|
|
mount: "repo".to_string(),
|
|
name: "makepad-example-splash".to_string(),
|
|
});
|
|
|
|
let child_started = wait_for_message(&connection, Duration::from_secs(10), |msg| {
|
|
matches!(
|
|
msg,
|
|
HubToClient::BuildStarted {
|
|
build_id: _,
|
|
mount,
|
|
package,
|
|
} if mount == "repo" && package == "makepad-example-splash"
|
|
)
|
|
})
|
|
.expect("did not receive child BuildStarted");
|
|
let child_build_id = match child_started {
|
|
HubToClient::BuildStarted { build_id, .. } => build_id,
|
|
_ => unreachable!(),
|
|
};
|
|
|
|
let child_stopped = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(20),
|
|
|msg| matches!(msg, HubToClient::BuildStopped { build_id, exit_code: Some(0) } if *build_id == child_build_id),
|
|
);
|
|
assert!(
|
|
child_stopped.is_some(),
|
|
"did not receive successful child BuildStopped"
|
|
);
|
|
|
|
let query_id = connection.send(ClientToHub::QueryLogs {
|
|
build_id: Some(child_build_id),
|
|
level: None,
|
|
source: None,
|
|
file: None,
|
|
pattern: Some("hello from clicked item".to_string()),
|
|
is_regex: None,
|
|
since_index: None,
|
|
live: Some(false),
|
|
});
|
|
let log_results = wait_for_message(&connection, Duration::from_secs(3), |msg| {
|
|
matches!(
|
|
msg,
|
|
HubToClient::QueryLogResults {
|
|
query_id: id, ..
|
|
} if *id == query_id
|
|
)
|
|
})
|
|
.expect("did not receive child QueryLogResults");
|
|
|
|
match log_results {
|
|
HubToClient::QueryLogResults { entries, done, .. } => {
|
|
assert!(done);
|
|
let messages: Vec<String> = entries
|
|
.iter()
|
|
.map(|entry| entry.1.message.clone())
|
|
.collect();
|
|
assert!(
|
|
entries
|
|
.iter()
|
|
.any(|entry| entry.1.message.contains("hello from clicked item")),
|
|
"expected child build log output, got {:?}",
|
|
messages
|
|
);
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn run_item_binds_self_to_registered_item() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::write(
|
|
dir.path().join("makepad.splash"),
|
|
"use mod.std\nuse mod.hub\nhub.set_run_items([{name:\"hello\" package:\"self-bound\" in_studio:true on_run:fn(){std.println(self.package) if me is nil {std.println(\"me-unbound\")}}}])\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let _ = connection.send(ClientToHub::ObserveMount {
|
|
mount: "repo".to_string(),
|
|
primary: Some(true),
|
|
});
|
|
let _run_items = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(3),
|
|
|msg| matches!(msg, HubToClient::RunItems { mount, items } if mount == "repo" && items.len() == 1),
|
|
)
|
|
.expect("did not receive RunItems");
|
|
|
|
let _ = connection.send(ClientToHub::RunItem {
|
|
mount: "repo".to_string(),
|
|
name: "hello".to_string(),
|
|
});
|
|
let _ = drain_messages(&connection, Duration::from_millis(250));
|
|
|
|
let query_id = connection.send(ClientToHub::QueryLogs {
|
|
build_id: None,
|
|
level: None,
|
|
source: None,
|
|
file: None,
|
|
pattern: None,
|
|
is_regex: None,
|
|
since_index: None,
|
|
live: Some(false),
|
|
});
|
|
let log_results = wait_for_message(&connection, Duration::from_secs(3), |msg| {
|
|
matches!(
|
|
msg,
|
|
HubToClient::QueryLogResults {
|
|
query_id: id, ..
|
|
} if *id == query_id
|
|
)
|
|
})
|
|
.expect("did not receive QueryLogResults");
|
|
|
|
match log_results {
|
|
HubToClient::QueryLogResults { entries, done, .. } => {
|
|
assert!(done);
|
|
let messages: Vec<String> = entries
|
|
.iter()
|
|
.map(|entry| entry.1.message.clone())
|
|
.collect();
|
|
assert!(
|
|
entries
|
|
.iter()
|
|
.any(|entry| entry.1.message.contains("self-bound")),
|
|
"expected self-bound log in splash logs, got {:?}",
|
|
messages
|
|
);
|
|
assert!(
|
|
entries
|
|
.iter()
|
|
.any(|entry| entry.1.message.contains("me-unbound")),
|
|
"expected me-unbound log in splash logs, got {:?}",
|
|
messages
|
|
);
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn run_item_reports_script_error_in_hub_run_args() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::write(
|
|
dir.path().join("makepad.splash"),
|
|
"use mod.hub\nhub.set_run_items([{name:\"broken\" in_studio:true on_run:fn(){hub.run(nil, \"cargo\", [\"run\" \"-p\" self.package])}}])\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let _ = connection.send(ClientToHub::ObserveMount {
|
|
mount: "repo".to_string(),
|
|
primary: Some(true),
|
|
});
|
|
let run_items = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(3),
|
|
|msg| matches!(msg, HubToClient::RunItems { mount, items } if mount == "repo" && items.len() == 1),
|
|
)
|
|
.expect("did not receive RunItems");
|
|
match run_items {
|
|
HubToClient::RunItems { items, .. } => {
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0].name, "broken");
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
|
|
let _ = connection.send(ClientToHub::RunItem {
|
|
mount: "repo".to_string(),
|
|
name: "broken".to_string(),
|
|
});
|
|
let unexpected_build = wait_for_message(&connection, Duration::from_millis(500), |msg| {
|
|
matches!(msg, HubToClient::BuildStarted { .. })
|
|
});
|
|
assert!(
|
|
unexpected_build.is_none(),
|
|
"expected invalid hub.run args to prevent spawning a child build, got {:?}",
|
|
unexpected_build
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn splash_runnable_prints_hello() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::write(
|
|
dir.path().join("makepad.splash"),
|
|
"use mod.std\nstd.println(\"hello\")\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let _ = connection.send(ClientToHub::Run {
|
|
mount: "repo".to_string(),
|
|
process: "makepad.splash".to_string(),
|
|
args: Vec::new(),
|
|
standalone: None,
|
|
env: None,
|
|
buildbox: None,
|
|
});
|
|
|
|
let _ = drain_messages(&connection, Duration::from_millis(250));
|
|
|
|
let query_id = connection.send(ClientToHub::QueryLogs {
|
|
build_id: None,
|
|
level: None,
|
|
source: None,
|
|
file: None,
|
|
pattern: Some("hello".to_string()),
|
|
is_regex: None,
|
|
since_index: None,
|
|
live: Some(false),
|
|
});
|
|
let log_results = wait_for_message(&connection, Duration::from_secs(3), |msg| {
|
|
matches!(
|
|
msg,
|
|
HubToClient::QueryLogResults {
|
|
query_id: id, ..
|
|
} if *id == query_id
|
|
)
|
|
})
|
|
.expect("did not receive QueryLogResults");
|
|
|
|
match log_results {
|
|
HubToClient::QueryLogResults { entries, done, .. } => {
|
|
assert!(done);
|
|
let messages: Vec<String> = entries
|
|
.iter()
|
|
.map(|entry| entry.1.message.clone())
|
|
.collect();
|
|
assert!(
|
|
entries
|
|
.iter()
|
|
.any(|entry| entry.1.message.contains("hello")),
|
|
"expected hello in splash logs, got {:?}",
|
|
messages
|
|
);
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn observe_mount_auto_starts_splash() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::write(
|
|
dir.path().join("makepad.splash"),
|
|
"use mod.std\nstd.println(\"hello\")\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let _ = connection.send(ClientToHub::ObserveMount {
|
|
mount: "repo".to_string(),
|
|
primary: Some(true),
|
|
});
|
|
|
|
let _ = drain_messages(&connection, Duration::from_millis(250));
|
|
|
|
let query_id = connection.send(ClientToHub::QueryLogs {
|
|
build_id: None,
|
|
level: None,
|
|
source: None,
|
|
file: None,
|
|
pattern: Some("hello".to_string()),
|
|
is_regex: None,
|
|
since_index: None,
|
|
live: Some(false),
|
|
});
|
|
let log_results = wait_for_message(&connection, Duration::from_secs(3), |msg| {
|
|
matches!(
|
|
msg,
|
|
HubToClient::QueryLogResults {
|
|
query_id: id, ..
|
|
} if *id == query_id
|
|
)
|
|
})
|
|
.expect("did not receive QueryLogResults");
|
|
|
|
match log_results {
|
|
HubToClient::QueryLogResults { entries, done, .. } => {
|
|
assert!(done);
|
|
assert!(
|
|
entries
|
|
.iter()
|
|
.any(|entry| entry.1.message.contains("hello")),
|
|
"expected hello in splash logs"
|
|
);
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn observe_mount_reload_splash_after_save() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::write(
|
|
dir.path().join("makepad.splash"),
|
|
"use mod.std\nstd.println(\"one\")\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let _ = connection.send(ClientToHub::ObserveMount {
|
|
mount: "repo".to_string(),
|
|
primary: Some(true),
|
|
});
|
|
|
|
let _ = drain_messages(&connection, Duration::from_millis(250));
|
|
|
|
let new_content = "use mod.std\nstd.println(\"two\")\n".to_string();
|
|
let _ = connection.send(ClientToHub::SaveTextFile {
|
|
path: "repo/makepad.splash".to_string(),
|
|
content: new_content,
|
|
});
|
|
|
|
let saved = wait_for_message(&connection, Duration::from_secs(3), |msg| {
|
|
matches!(
|
|
msg,
|
|
HubToClient::TextFileSaved { path, .. } if path == "repo/makepad.splash"
|
|
)
|
|
});
|
|
assert!(saved.is_some(), "did not receive TextFileSaved");
|
|
|
|
let _ = drain_messages(&connection, Duration::from_millis(500));
|
|
|
|
let query_id = connection.send(ClientToHub::QueryLogs {
|
|
build_id: None,
|
|
level: None,
|
|
source: None,
|
|
file: None,
|
|
pattern: Some("two".to_string()),
|
|
is_regex: None,
|
|
since_index: None,
|
|
live: Some(false),
|
|
});
|
|
let log_results = wait_for_message(&connection, Duration::from_secs(3), |msg| {
|
|
matches!(
|
|
msg,
|
|
HubToClient::QueryLogResults {
|
|
query_id: id, ..
|
|
} if *id == query_id
|
|
)
|
|
})
|
|
.expect("did not receive QueryLogResults for reloaded splash");
|
|
|
|
match log_results {
|
|
HubToClient::QueryLogResults { entries, done, .. } => {
|
|
assert!(done);
|
|
assert!(
|
|
entries.iter().any(|entry| entry.1.message.contains("two")),
|
|
"expected updated splash logs"
|
|
);
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn observe_mount_recovers_splash_after_error_on_followup_save() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::write(
|
|
dir.path().join("makepad.splash"),
|
|
"use mod.hub\nhub.set_run_items([{name:\"one\" in_studio:true on_run:fn(){}}])\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let _ = connection.send(ClientToHub::ObserveMount {
|
|
mount: "repo".to_string(),
|
|
primary: Some(true),
|
|
});
|
|
|
|
let initial_run_items = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(3),
|
|
|msg| matches!(msg, HubToClient::RunItems { mount, items } if mount == "repo" && items.len() == 1 && items[0].name == "one"),
|
|
);
|
|
assert!(
|
|
initial_run_items.is_some(),
|
|
"did not receive initial RunItems from splash"
|
|
);
|
|
|
|
let _ = connection.send(ClientToHub::SaveTextFile {
|
|
path: "repo/makepad.splash".to_string(),
|
|
content: "use mod.hub\nhub.set_run_items([\n".to_string(),
|
|
});
|
|
let broken_saved = wait_for_message(&connection, Duration::from_secs(3), |msg| {
|
|
matches!(
|
|
msg,
|
|
HubToClient::TextFileSaved { path, .. } if path == "repo/makepad.splash"
|
|
)
|
|
});
|
|
assert!(
|
|
broken_saved.is_some(),
|
|
"did not receive broken TextFileSaved"
|
|
);
|
|
|
|
let empty_run_items = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(6),
|
|
|msg| matches!(msg, HubToClient::RunItems { mount, items } if mount == "repo" && items.is_empty()),
|
|
);
|
|
assert!(
|
|
empty_run_items.is_some(),
|
|
"did not receive empty RunItems after broken splash"
|
|
);
|
|
|
|
let _ = connection.send(ClientToHub::SaveTextFile {
|
|
path: "repo/makepad.splash".to_string(),
|
|
content: "use mod.hub\nhub.set_run_items([{name:\"two\" in_studio:true on_run:fn(){}}])\n"
|
|
.to_string(),
|
|
});
|
|
let fixed_saved = wait_for_message(&connection, Duration::from_secs(3), |msg| {
|
|
matches!(
|
|
msg,
|
|
HubToClient::TextFileSaved { path, .. } if path == "repo/makepad.splash"
|
|
)
|
|
});
|
|
assert!(fixed_saved.is_some(), "did not receive fixed TextFileSaved");
|
|
|
|
let recovered_run_items = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(6),
|
|
|msg| matches!(msg, HubToClient::RunItems { mount, items } if mount == "repo" && items.len() == 1 && items[0].name == "two"),
|
|
);
|
|
assert!(
|
|
recovered_run_items.is_some(),
|
|
"did not receive recovered RunItems after fixing splash"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn external_makepad_splash_fix_restarts_failed_splash() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
let splash_path = dir.path().join("makepad.splash");
|
|
fs::write(
|
|
&splash_path,
|
|
"use mod.hub\nhub.set_run_items([{name:\"one\" in_studio:true on_run:fn(){}}])\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let _ = connection.send(ClientToHub::ObserveMount {
|
|
mount: "repo".to_string(),
|
|
primary: Some(true),
|
|
});
|
|
|
|
let initial_run_items = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(3),
|
|
|msg| matches!(msg, HubToClient::RunItems { mount, items } if mount == "repo" && items.len() == 1 && items[0].name == "one"),
|
|
);
|
|
assert!(
|
|
initial_run_items.is_some(),
|
|
"did not receive initial RunItems from splash"
|
|
);
|
|
|
|
let _ = connection.send(ClientToHub::SaveTextFile {
|
|
path: "repo/makepad.splash".to_string(),
|
|
content: "use mod.hub\nhub.set_run_items([\n".to_string(),
|
|
});
|
|
let broken_saved = wait_for_message(&connection, Duration::from_secs(3), |msg| {
|
|
matches!(
|
|
msg,
|
|
HubToClient::TextFileSaved { path, .. } if path == "repo/makepad.splash"
|
|
)
|
|
});
|
|
assert!(
|
|
broken_saved.is_some(),
|
|
"did not receive broken TextFileSaved"
|
|
);
|
|
|
|
let empty_run_items = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(6),
|
|
|msg| matches!(msg, HubToClient::RunItems { mount, items } if mount == "repo" && items.is_empty()),
|
|
);
|
|
assert!(
|
|
empty_run_items.is_some(),
|
|
"did not receive empty RunItems after broken splash"
|
|
);
|
|
|
|
std::thread::sleep(Duration::from_millis(400));
|
|
let fixed_tmp = dir.path().join("makepad.splash.tmp");
|
|
fs::write(
|
|
&fixed_tmp,
|
|
"use mod.hub\nhub.set_run_items([{name:\"two\" in_studio:true on_run:fn(){}}])\n",
|
|
)
|
|
.unwrap();
|
|
fs::rename(&fixed_tmp, &splash_path).unwrap();
|
|
|
|
let recovered_run_items = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(8),
|
|
|msg| matches!(msg, HubToClient::RunItems { mount, items } if mount == "repo" && items.len() == 1 && items[0].name == "two"),
|
|
);
|
|
assert!(
|
|
recovered_run_items.is_some(),
|
|
"did not receive recovered RunItems after external splash rewrite"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn file_watch_emits_single_path_delta_without_full_tree_reload() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join("src")).unwrap();
|
|
fs::write(dir.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let _ = connection.send(ClientToHub::LoadFileTree {
|
|
mount: "repo".to_string(),
|
|
});
|
|
let _ = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(3),
|
|
|msg| matches!(msg, HubToClient::FileTree { mount, .. } if mount == "repo"),
|
|
)
|
|
.expect("did not receive initial file tree");
|
|
|
|
let _ = connection.send(ClientToHub::SaveTextFile {
|
|
path: "repo/src/lib.rs".to_string(),
|
|
content: "pub fn hi() { let _x = 1; }\n".to_string(),
|
|
});
|
|
|
|
let diff_msg = wait_for_message(&connection, Duration::from_secs(5), |msg| {
|
|
matches!(
|
|
msg,
|
|
HubToClient::FileTreeDiff { mount, changes }
|
|
if mount == "repo"
|
|
&& changes.len() == 1
|
|
&& matches!(
|
|
&changes[0],
|
|
makepad_studio_protocol::hub_protocol::FileTreeChange::Added { path, .. }
|
|
| makepad_studio_protocol::hub_protocol::FileTreeChange::Modified { path, .. }
|
|
| makepad_studio_protocol::hub_protocol::FileTreeChange::Removed { path }
|
|
if path.starts_with("repo/")
|
|
)
|
|
)
|
|
})
|
|
.expect("did not receive path-scoped filetree delta");
|
|
|
|
match diff_msg {
|
|
HubToClient::FileTreeDiff { changes, .. } => {
|
|
assert_eq!(changes.len(), 1);
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
|
|
let trailing = drain_messages(&connection, Duration::from_millis(500));
|
|
assert!(
|
|
!trailing
|
|
.iter()
|
|
.any(|msg| matches!(msg, HubToClient::FileTree { mount, .. } if mount == "repo")),
|
|
"unexpected full file-tree reload after delta update"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn save_text_file_does_not_echo_file_changed_to_saving_client() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join("src")).unwrap();
|
|
fs::write(dir.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let _ = connection.send(ClientToHub::LoadFileTree {
|
|
mount: "repo".to_string(),
|
|
});
|
|
let _ = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(3),
|
|
|msg| matches!(msg, HubToClient::FileTree { mount, .. } if mount == "repo"),
|
|
)
|
|
.expect("did not receive initial file tree");
|
|
|
|
let _ = connection.send(ClientToHub::SaveTextFile {
|
|
path: "repo/src/lib.rs".to_string(),
|
|
content: "pub fn hi() { let _x = 7; }\n".to_string(),
|
|
});
|
|
|
|
let messages = drain_messages(&connection, Duration::from_millis(1200));
|
|
assert!(
|
|
messages.iter().any(|msg| {
|
|
matches!(
|
|
msg,
|
|
HubToClient::TextFileSaved { path, .. } if path == "repo/src/lib.rs"
|
|
)
|
|
}),
|
|
"did not receive TextFileSaved after save request"
|
|
);
|
|
assert!(
|
|
!messages.iter().any(|msg| {
|
|
matches!(
|
|
msg,
|
|
HubToClient::FileChanged { path } if path == "repo/src/lib.rs"
|
|
)
|
|
}),
|
|
"unexpected FileChanged echo for saving client"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn file_watch_ignores_makepad_term_writes() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join(".makepad")).unwrap();
|
|
fs::write(dir.path().join(".makepad/a.term"), "").unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let _ = connection.send(ClientToHub::LoadFileTree {
|
|
mount: "repo".to_string(),
|
|
});
|
|
let _ = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(3),
|
|
|msg| matches!(msg, HubToClient::FileTree { mount, .. } if mount == "repo"),
|
|
)
|
|
.expect("did not receive initial file tree");
|
|
|
|
let _ = connection.send(ClientToHub::SaveTextFile {
|
|
path: "repo/.makepad/a.term".to_string(),
|
|
content: "echo hi\n".to_string(),
|
|
});
|
|
|
|
let messages = drain_messages(&connection, Duration::from_millis(900));
|
|
assert!(
|
|
!messages.iter().any(|msg| {
|
|
matches!(msg, HubToClient::FileTreeDiff { mount, .. } if mount == "repo")
|
|
}),
|
|
"expected .makepad terminal writes to be ignored by fs delta watcher"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn file_watch_emits_hidden_directory_writes() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let _ = connection.send(ClientToHub::LoadFileTree {
|
|
mount: "repo".to_string(),
|
|
});
|
|
let _ = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(3),
|
|
|msg| matches!(msg, HubToClient::FileTree { mount, .. } if mount == "repo"),
|
|
)
|
|
.expect("did not receive initial file tree");
|
|
|
|
let _ = connection.send(ClientToHub::SaveTextFile {
|
|
path: "repo/.hidden/a.txt".to_string(),
|
|
content: "hello\n".to_string(),
|
|
});
|
|
|
|
let diff = wait_for_message(&connection, Duration::from_secs(5), |msg| {
|
|
matches!(
|
|
msg,
|
|
HubToClient::FileTreeDiff { mount, changes }
|
|
if mount == "repo"
|
|
&& changes.iter().any(|change| {
|
|
matches!(
|
|
change,
|
|
makepad_studio_protocol::hub_protocol::FileTreeChange::Added { path, .. }
|
|
| makepad_studio_protocol::hub_protocol::FileTreeChange::Modified { path, .. }
|
|
| makepad_studio_protocol::hub_protocol::FileTreeChange::Removed { path }
|
|
if path == "repo/.hidden/a.txt"
|
|
)
|
|
})
|
|
)
|
|
});
|
|
assert!(
|
|
diff.is_some(),
|
|
"expected hidden-directory writes to emit deltas"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn file_watch_picks_up_external_new_file() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join("src")).unwrap();
|
|
fs::write(dir.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let _ = connection.send(ClientToHub::LoadFileTree {
|
|
mount: "repo".to_string(),
|
|
});
|
|
let _ = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(3),
|
|
|msg| matches!(msg, HubToClient::FileTree { mount, .. } if mount == "repo"),
|
|
)
|
|
.expect("did not receive initial file tree");
|
|
|
|
fs::write(dir.path().join("src/new_file.rs"), "pub fn new_file() {}\n").unwrap();
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(6);
|
|
let mut saw_new_file = false;
|
|
while Instant::now() < deadline {
|
|
let Some(msg) = connection.recv_timeout(Duration::from_millis(120)) else {
|
|
continue;
|
|
};
|
|
match msg {
|
|
HubToClient::FileTreeDiff { mount, changes } if mount == "repo" => {
|
|
if changes.iter().any(|change| {
|
|
matches!(
|
|
change,
|
|
makepad_studio_protocol::hub_protocol::FileTreeChange::Added { path, .. }
|
|
if path == "repo/src/new_file.rs"
|
|
)
|
|
}) {
|
|
saw_new_file = true;
|
|
break;
|
|
}
|
|
}
|
|
HubToClient::FileTree { mount, data } if mount == "repo" => {
|
|
if data
|
|
.nodes
|
|
.iter()
|
|
.any(|node| node.path == "repo/src/new_file.rs")
|
|
{
|
|
saw_new_file = true;
|
|
break;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
saw_new_file,
|
|
"did not observe file-tree update for externally created file"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn file_watch_emits_file_changed_for_external_write() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join("src")).unwrap();
|
|
fs::write(dir.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let _ = connection.send(ClientToHub::LoadFileTree {
|
|
mount: "repo".to_string(),
|
|
});
|
|
let _ = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(3),
|
|
|msg| matches!(msg, HubToClient::FileTree { mount, .. } if mount == "repo"),
|
|
)
|
|
.expect("did not receive initial file tree");
|
|
|
|
// External write (not via SaveTextFile) should still notify UI clients.
|
|
fs::write(
|
|
dir.path().join("src/lib.rs"),
|
|
"pub fn hi() { let _extern = 1; }\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let changed = wait_for_message(&connection, Duration::from_secs(6), |msg| {
|
|
matches!(
|
|
msg,
|
|
HubToClient::FileChanged { path }
|
|
if path == "repo/src/lib.rs" || path == "repo"
|
|
)
|
|
});
|
|
assert!(
|
|
changed.is_some(),
|
|
"did not observe FileChanged for external write"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn read_text_file_returns_fresh_content_after_external_write() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join("src")).unwrap();
|
|
fs::write(dir.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let _ = connection.send(ClientToHub::OpenTextFile {
|
|
path: "repo/src/lib.rs".to_string(),
|
|
});
|
|
let opened = wait_for_message(&connection, Duration::from_secs(3), |msg| {
|
|
matches!(
|
|
msg,
|
|
HubToClient::TextFileOpened { path, content, .. }
|
|
if path == "repo/src/lib.rs" && content == "pub fn hi() {}\n"
|
|
)
|
|
});
|
|
assert!(opened.is_some(), "did not receive initial TextFileOpened");
|
|
|
|
fs::write(
|
|
dir.path().join("src/lib.rs"),
|
|
"pub fn hi() { let external = 1; }\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let _ = connection.send(ClientToHub::ReadTextFile {
|
|
path: "repo/src/lib.rs".to_string(),
|
|
});
|
|
let read = wait_for_message(&connection, Duration::from_secs(3), |msg| {
|
|
matches!(
|
|
msg,
|
|
HubToClient::TextFileRead { path, content }
|
|
if path == "repo/src/lib.rs"
|
|
&& content == "pub fn hi() { let external = 1; }\n"
|
|
)
|
|
});
|
|
assert!(
|
|
read.is_some(),
|
|
"did not receive fresh TextFileRead content after external write"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn file_watch_picks_up_external_removed_directory() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join("src/nested")).unwrap();
|
|
fs::write(dir.path().join("src/nested/mod.rs"), "pub fn nested() {}\n").unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let _ = connection.send(ClientToHub::LoadFileTree {
|
|
mount: "repo".to_string(),
|
|
});
|
|
let _ = wait_for_message(
|
|
&connection,
|
|
Duration::from_secs(3),
|
|
|msg| matches!(msg, HubToClient::FileTree { mount, .. } if mount == "repo"),
|
|
)
|
|
.expect("did not receive initial file tree");
|
|
|
|
fs::remove_dir_all(dir.path().join("src/nested")).unwrap();
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(6);
|
|
let mut saw_nested_removed = false;
|
|
while Instant::now() < deadline {
|
|
let Some(msg) = connection.recv_timeout(Duration::from_millis(120)) else {
|
|
continue;
|
|
};
|
|
match msg {
|
|
HubToClient::FileTreeDiff { mount, changes } if mount == "repo" => {
|
|
if changes.iter().any(|change| {
|
|
matches!(
|
|
change,
|
|
makepad_studio_protocol::hub_protocol::FileTreeChange::Removed { path }
|
|
if path == "repo/src/nested" || path.starts_with("repo/src/nested/")
|
|
)
|
|
}) {
|
|
saw_nested_removed = true;
|
|
break;
|
|
}
|
|
}
|
|
HubToClient::FileTree { mount, data } if mount == "repo" => {
|
|
if !data.nodes.iter().any(|node| {
|
|
node.path == "repo/src/nested" || node.path.starts_with("repo/src/nested/")
|
|
}) {
|
|
saw_nested_removed = true;
|
|
break;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
saw_nested_removed,
|
|
"did not observe file-tree update for externally removed directory"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn find_in_files_defaults_to_rs_md_toml_and_returns_concise_hits() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join("src")).unwrap();
|
|
fs::write(
|
|
dir.path().join("src/lib.rs"),
|
|
"pub fn alpha() {\n let marker = \"needle\";\n}\n",
|
|
)
|
|
.unwrap();
|
|
fs::write(dir.path().join("README.md"), "needle in markdown\n").unwrap();
|
|
fs::write(
|
|
dir.path().join("notes.txt"),
|
|
"needle in txt should be excluded by default\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let query_id = connection.send(ClientToHub::FindInFiles {
|
|
mount: Some("repo".to_string()),
|
|
pattern: "needle".to_string(),
|
|
is_regex: Some(false),
|
|
glob: None,
|
|
max_results: Some(100),
|
|
});
|
|
|
|
let msg = wait_for_message(&connection, Duration::from_secs(4), |msg| {
|
|
matches!(
|
|
msg,
|
|
HubToClient::SearchFileResults { query_id: id, done: true, .. } if *id == query_id
|
|
)
|
|
})
|
|
.expect("did not receive SearchFileResults");
|
|
|
|
match msg {
|
|
HubToClient::SearchFileResults { results, done, .. } => {
|
|
assert!(done);
|
|
assert!(
|
|
results.iter().any(|item| item.path == "repo/src/lib.rs"),
|
|
"expected .rs match"
|
|
);
|
|
assert!(
|
|
results.iter().any(|item| item.path == "repo/README.md"),
|
|
"expected .md match"
|
|
);
|
|
assert!(
|
|
!results.iter().any(|item| item.path == "repo/notes.txt"),
|
|
"unexpected .txt match without glob override"
|
|
);
|
|
assert!(
|
|
results.iter().all(|item| !item.line_text.is_empty()),
|
|
"expected concise line_text in all hits"
|
|
);
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn find_in_files_regex_respects_max_results() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join("src")).unwrap();
|
|
fs::write(
|
|
dir.path().join("src/lib.rs"),
|
|
"pub fn alpha() {\n let marker = \"needle\";\n let marker2 = \"needle\";\n}\n",
|
|
)
|
|
.unwrap();
|
|
fs::write(dir.path().join("README.md"), "needle in markdown\n").unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let query_id = connection.send(ClientToHub::FindInFiles {
|
|
mount: Some("repo".to_string()),
|
|
pattern: "needle".to_string(),
|
|
is_regex: Some(true),
|
|
glob: None,
|
|
max_results: Some(1),
|
|
});
|
|
|
|
let msg = wait_for_message(&connection, Duration::from_secs(4), |msg| {
|
|
matches!(
|
|
msg,
|
|
HubToClient::SearchFileResults { query_id: id, done: true, .. } if *id == query_id
|
|
)
|
|
})
|
|
.expect("did not receive SearchFileResults");
|
|
|
|
match msg {
|
|
HubToClient::SearchFileResults { results, done, .. } => {
|
|
assert!(done);
|
|
assert_eq!(results.len(), 1, "regex search should stop at max_results");
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn read_text_range_returns_line_window_and_total_line_count() {
|
|
let dir = makepad_studio_hub::test_support::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join("src")).unwrap();
|
|
fs::write(
|
|
dir.path().join("src/lib.rs"),
|
|
"line-1\nline-2\nline-3\nline-4\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let config = HubConfig {
|
|
mounts: vec![MountConfig {
|
|
name: "repo".to_string(),
|
|
path: dir.path().to_path_buf(),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut connection = StudioHub::start_in_process(config).expect("start in-process backend");
|
|
|
|
let _ = connection.send(ClientToHub::ReadTextRange {
|
|
path: "repo/src/lib.rs".to_string(),
|
|
start_line: 2,
|
|
end_line: 3,
|
|
});
|
|
|
|
let msg = wait_for_message(&connection, Duration::from_secs(3), |msg| {
|
|
matches!(
|
|
msg,
|
|
HubToClient::TextFileRange {
|
|
path,
|
|
start_line,
|
|
end_line,
|
|
total_lines,
|
|
content
|
|
} if path == "repo/src/lib.rs"
|
|
&& *start_line == 2
|
|
&& *end_line == 3
|
|
&& *total_lines == 4
|
|
&& content == "line-2\nline-3"
|
|
)
|
|
});
|
|
|
|
assert!(msg.is_some(), "did not receive expected TextFileRange");
|
|
}
|