#![cfg(feature = "plugins")]
#[cfg(unix)]
use crate::common::harness::HarnessOptions;
use crate::common::harness::{copy_plugin, copy_plugin_lib, EditorTestHarness};
use crossterm::event::{KeyCode, KeyModifiers};
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
fn set_up_workspace() -> (tempfile::TempDir, PathBuf) {
fresh::i18n::set_locale("en");
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().canonicalize().unwrap();
let dc = workspace.join(".devcontainer");
fs::create_dir_all(&dc).unwrap();
fs::write(
dc.join("devcontainer.json"),
r#"{
"name": "fake-usability",
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"remoteUser": "vscode"
}"#,
)
.unwrap();
let plugins_dir = workspace.join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
copy_plugin_lib(&plugins_dir);
copy_plugin(&plugins_dir, "devcontainer");
(temp, workspace)
}
fn wait_for_devcontainer_commands(harness: &mut EditorTestHarness) {
harness
.wait_until(|h| {
let reg = h.editor().command_registry().read().unwrap();
let cmds = reg.get_all();
let attach_cmd = cmds.iter().find(|c| c.name == "%cmd.attach");
let rebuild_cmd = cmds.iter().find(|c| c.name == "%cmd.rebuild");
attach_cmd
.map(|c| c.get_localized_name() == "Dev Container: Attach")
.unwrap_or(false)
&& rebuild_cmd
.map(|c| c.get_localized_name() == "Dev Container: Rebuild")
.unwrap_or(false)
})
.unwrap();
}
fn dev_container_command_names(harness: &EditorTestHarness) -> Vec<String> {
let reg = harness.editor().command_registry().read().unwrap();
reg.get_all()
.iter()
.filter(|c| c.name.starts_with("%cmd."))
.map(|c| c.name.to_string())
.collect()
}
#[cfg(unix)]
fn attach_via_fake(harness: &mut EditorTestHarness) {
harness
.wait_until(|h| {
let reg = h.editor().command_registry().read().unwrap();
reg.get_all().iter().any(|c| c.name == "%cmd.attach")
})
.unwrap();
harness.editor().fire_plugins_loaded_hook();
harness
.wait_until(|h| {
let s = h.screen_to_string();
s.contains("Dev Container Detected") && s.contains("Reopen in Container")
})
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
let max_iters = 200;
for _ in 0..max_iters {
harness.tick_and_render().unwrap();
if let Some(auth) = harness.editor_mut().take_pending_authority() {
harness.editor_mut().set_boot_authority(auth);
return;
}
if harness
.editor()
.authority()
.display_label
.starts_with("Container:")
{
return;
}
std::thread::sleep(Duration::from_millis(50));
harness.advance_time(Duration::from_millis(50));
}
panic!(
"container authority never staged; screen:\n{}",
harness.screen_to_string()
);
}
#[cfg(unix)]
#[test]
#[ignore = "harness shortcuts the post-rebuild editor restart; needs real restart support to repro"]
fn dev_container_commands_persist_after_rebuild_with_broken_config() {
let (_temp, workspace) = set_up_workspace();
let mut harness = EditorTestHarness::create(
160,
40,
HarnessOptions::new()
.with_working_dir(workspace.clone())
.with_fake_devcontainer(),
)
.unwrap();
harness.tick_and_render().unwrap();
attach_via_fake(&mut harness);
let before = dev_container_command_names(&harness);
assert!(
before.iter().any(|n| n == "%cmd.rebuild"),
"`%cmd.rebuild` must be registered after attach (sanity); registry has: {before:?}"
);
assert!(
before.iter().any(|n| n == "%cmd.open_config"),
"`%cmd.open_config` must be registered after attach (sanity); registry has: {before:?}"
);
fs::write(
workspace.join(".devcontainer").join("devcontainer.json"),
r#"{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu"
"name_typo_extra_field" "broken_no_colon",
}"#,
)
.unwrap();
harness
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
.unwrap();
harness.wait_for_prompt().unwrap();
harness.type_text("Dev Container: Rebuild").unwrap();
harness
.wait_until(|h| h.screen_to_string().contains("Dev Container: Rebuild"))
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
for _ in 0..200 {
harness.tick_and_render().unwrap();
if let Some(auth) = harness.editor_mut().take_pending_authority() {
harness.editor_mut().set_boot_authority(auth);
}
std::thread::sleep(Duration::from_millis(50));
harness.advance_time(Duration::from_millis(50));
}
let after = dev_container_command_names(&harness);
let lost: Vec<_> = before.iter().filter(|n| !after.contains(n)).collect();
assert!(
lost.is_empty(),
"Dev Container palette commands must persist across a Rebuild \
with malformed devcontainer.json (otherwise the user has no \
in-editor recovery path). Lost commands: {lost:?}\n\
Before: {before:?}\n\
After: {after:?}"
);
}
#[test]
#[ignore = "harness PTY is too tall to crowd the popup off-screen; needs smaller PTY or popup-bounds accessor to repro"]
fn palette_popup_renders_when_layout_has_many_splits() {
let (_temp, workspace) = set_up_workspace();
let mut harness = EditorTestHarness::with_working_dir(160, 40, workspace).unwrap();
harness.tick_and_render().unwrap();
wait_for_devcontainer_commands(&mut harness);
for _ in 0..5 {
harness
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
.unwrap();
harness.wait_for_prompt().unwrap();
harness.type_text("Split Horizontal").unwrap();
harness
.wait_until(|h| h.screen_to_string().contains("Split Horizontal"))
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.wait_for_prompt_closed().unwrap();
}
harness
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
.unwrap();
harness.wait_for_prompt().unwrap();
harness.type_text("Dev Container: Rebuild").unwrap();
harness
.wait_until(|h| h.screen_to_string().contains("Dev Container: Rebuild"))
.unwrap();
}
#[cfg(unix)]
#[test]
fn palette_attach_command_hidden_when_already_attached() {
let (_temp, workspace) = set_up_workspace();
let mut harness = EditorTestHarness::create(
160,
40,
HarnessOptions::new()
.with_working_dir(workspace)
.with_fake_devcontainer(),
)
.unwrap();
harness.tick_and_render().unwrap();
attach_via_fake(&mut harness);
harness
.wait_until(|h| {
let reg = h.editor().command_registry().read().unwrap();
!reg.get_all().iter().any(|c| c.name == "%cmd.attach")
})
.unwrap();
let reg = harness.editor().command_registry().read().unwrap();
let attach_visible = reg.get_all().iter().any(|c| c.name == "%cmd.attach");
assert!(
!attach_visible,
"`Dev Container: Attach` must not be offered while already attached \
(display label: {:?}); only `Detach` should be state-relevant.",
harness.editor().authority().display_label,
);
}
#[test]
fn broken_devcontainer_json_surfaces_parse_error_in_status_bar() {
fresh::i18n::set_locale("en");
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().canonicalize().unwrap();
let dc = workspace.join(".devcontainer");
fs::create_dir_all(&dc).unwrap();
fs::write(
dc.join("devcontainer.json"),
r#"{
"name": broken,
"image": "ubuntu:22.04"
"#,
)
.unwrap();
let plugins_dir = workspace.join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
copy_plugin_lib(&plugins_dir);
copy_plugin(&plugins_dir, "devcontainer");
let mut harness = EditorTestHarness::with_working_dir(160, 40, workspace).unwrap();
for _ in 0..20 {
harness.tick_and_render().unwrap();
std::thread::sleep(Duration::from_millis(25));
harness.advance_time(Duration::from_millis(25));
}
let screen = harness.screen_to_string();
assert!(
screen.contains("devcontainer.json could not be parsed"),
"broken devcontainer.json must surface a parse failure in the status bar; \
screen:\n{screen}"
);
}
#[test]
fn broken_devcontainer_json_keeps_recovery_commands_registered() {
fresh::i18n::set_locale("en");
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().canonicalize().unwrap();
let dc = workspace.join(".devcontainer");
fs::create_dir_all(&dc).unwrap();
fs::write(
dc.join("devcontainer.json"),
r#"{
"name": broken,
"image": "ubuntu:22.04"
"#,
)
.unwrap();
let plugins_dir = workspace.join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
copy_plugin_lib(&plugins_dir);
copy_plugin(&plugins_dir, "devcontainer");
let mut harness = EditorTestHarness::with_working_dir(160, 40, workspace).unwrap();
for _ in 0..20 {
harness.tick_and_render().unwrap();
std::thread::sleep(Duration::from_millis(25));
harness.advance_time(Duration::from_millis(25));
}
let names = dev_container_command_names(&harness);
assert!(
names.iter().any(|n| n == "%cmd.open_config"),
"broken-config recovery: `%cmd.open_config` must stay registered \
so the user can open the broken file. Registered: {names:?}"
);
assert!(
names.iter().any(|n| n == "%cmd.show_build_logs"),
"broken-config recovery: `%cmd.show_build_logs` must stay registered \
so the user can see the last rebuild output. Registered: {names:?}"
);
assert!(
!names.iter().any(|n| n == "%cmd.rebuild"),
"broken-config recovery: `%cmd.rebuild` must be removed when \
config is unparseable (would call into `attach` with no config). \
Registered: {names:?}"
);
assert!(
!names.iter().any(|n| n == "%cmd.attach"),
"broken-config recovery: `%cmd.attach` must be removed when \
config is unparseable. Registered: {names:?}"
);
}
#[cfg(unix)]
#[test]
fn auto_forward_notify_fires_for_configured_port() {
fresh::i18n::set_locale("en");
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().canonicalize().unwrap();
let dc = workspace.join(".devcontainer");
fs::create_dir_all(&dc).unwrap();
fs::write(
dc.join("devcontainer.json"),
r#"{
"name": "auto-forward-test",
"image": "ubuntu:22.04",
"remoteUser": "vscode",
"forwardPorts": [9000],
"portsAttributes": {
"9000": { "onAutoForward": "notify", "label": "App" }
}
}"#,
)
.unwrap();
let plugins_dir = workspace.join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
copy_plugin_lib(&plugins_dir);
copy_plugin(&plugins_dir, "devcontainer");
std::env::set_var("FAKE_DC_PORTS", "9000");
let mut harness = EditorTestHarness::create(
160,
40,
HarnessOptions::new()
.with_working_dir(workspace.clone())
.with_fake_devcontainer(),
)
.unwrap();
harness.tick_and_render().unwrap();
attach_via_fake(&mut harness);
harness
.wait_until(|h| h.screen_to_string().contains("Port 9000 forwarded"))
.unwrap();
let screen = harness.screen_to_string();
std::env::remove_var("FAKE_DC_PORTS");
assert!(
screen.contains("Port 9000 forwarded"),
"configured `forwardPorts` entry with `onAutoForward: notify` must \
emit a toast when the port is bound; screen:\n{screen}"
);
}
#[cfg(unix)]
#[test]
fn rebuild_does_not_kill_open_terminal_buffer() {
let (_temp, workspace) = set_up_workspace();
let mut harness = EditorTestHarness::create(
160,
40,
HarnessOptions::new()
.with_working_dir(workspace.clone())
.with_fake_devcontainer(),
)
.unwrap();
harness.tick_and_render().unwrap();
harness.editor_mut().open_terminal();
harness.render().unwrap();
let pre_terminal = harness.editor().active_buffer();
assert!(
harness.editor().is_terminal_buffer(pre_terminal),
"open_terminal must produce an active terminal buffer"
);
attach_via_fake(&mut harness);
assert!(
harness.editor().is_terminal_buffer(pre_terminal),
"terminal buffer {pre_terminal:?} must survive the attach round-trip \
(the rebuild flow uses the same setAuthority path)"
);
}
#[cfg(unix)]
#[test]
fn rebuild_reuses_build_log_split_instead_of_stacking() {
let (_temp, workspace) = set_up_workspace();
let mut harness = EditorTestHarness::create(
160,
40,
HarnessOptions::new()
.with_working_dir(workspace.clone())
.with_fake_devcontainer(),
)
.unwrap();
harness.tick_and_render().unwrap();
attach_via_fake(&mut harness);
harness
.wait_until(|h| h.screen_to_string().contains("devcontainer-logs/build-"))
.unwrap();
let splits_after_attach = harness.editor().get_split_count();
harness
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
.unwrap();
harness.wait_for_prompt().unwrap();
harness.type_text("Dev Container: Rebuild").unwrap();
harness
.wait_until(|h| h.screen_to_string().contains("Dev Container: Rebuild"))
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
for _ in 0..200 {
harness.tick_and_render().unwrap();
if let Some(auth) = harness.editor_mut().take_pending_authority() {
harness.editor_mut().set_boot_authority(auth);
}
std::thread::sleep(Duration::from_millis(25));
harness.advance_time(Duration::from_millis(25));
}
let splits_after_rebuild = harness.editor().get_split_count();
assert_eq!(
splits_after_rebuild, splits_after_attach,
"Rebuild must reuse the build-log split, not stack a new one. \
splits after attach: {splits_after_attach}, \
splits after rebuild: {splits_after_rebuild}"
);
}
#[cfg(unix)]
#[test]
fn show_panels_reuse_single_split_instead_of_stacking() {
let (_temp, workspace) = set_up_workspace();
let mut harness = EditorTestHarness::create(
160,
40,
HarnessOptions::new()
.with_working_dir(workspace.clone())
.with_fake_devcontainer(),
)
.unwrap();
harness.tick_and_render().unwrap();
attach_via_fake(&mut harness);
harness
.wait_until(|h| h.screen_to_string().contains("devcontainer-logs/build-"))
.unwrap();
let baseline = harness.editor().get_split_count();
harness
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
.unwrap();
harness.wait_for_prompt().unwrap();
harness.type_text("Dev Container: Show Info").unwrap();
harness
.wait_until(|h| h.screen_to_string().contains("Dev Container: Show Info"))
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
for _ in 0..40 {
harness.tick_and_render().unwrap();
std::thread::sleep(Duration::from_millis(25));
harness.advance_time(Duration::from_millis(25));
}
let after_info = harness.editor().get_split_count();
harness
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
.unwrap();
harness.wait_for_prompt().unwrap();
harness.type_text("Dev Container: Show Container").unwrap();
harness
.wait_until(|h| {
h.screen_to_string()
.contains("Dev Container: Show Container")
})
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
for _ in 0..40 {
harness.tick_and_render().unwrap();
std::thread::sleep(Duration::from_millis(25));
harness.advance_time(Duration::from_millis(25));
}
let after_logs = harness.editor().get_split_count();
assert_eq!(
after_info, baseline,
"Show Info must reuse the existing panel split. \
baseline={baseline}, after_info={after_info}"
);
assert_eq!(
after_logs, baseline,
"Show Container Logs must reuse the existing panel split. \
baseline={baseline}, after_logs={after_logs}"
);
}
#[cfg(unix)]
#[test]
fn show_panels_keep_per_command_buffers_alive_across_commands() {
let (_temp, workspace) = set_up_workspace();
let mut harness = EditorTestHarness::create(
160,
40,
HarnessOptions::new()
.with_working_dir(workspace.clone())
.with_fake_devcontainer(),
)
.unwrap();
harness.tick_and_render().unwrap();
attach_via_fake(&mut harness);
harness
.wait_until(|h| h.screen_to_string().contains("devcontainer-logs/build-"))
.unwrap();
harness
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
.unwrap();
harness.wait_for_prompt().unwrap();
harness.type_text("Dev Container: Show Info").unwrap();
harness
.wait_until(|h| h.screen_to_string().contains("Dev Container: Show Info"))
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
for _ in 0..40 {
harness.tick_and_render().unwrap();
std::thread::sleep(Duration::from_millis(25));
harness.advance_time(Duration::from_millis(25));
}
harness
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
.unwrap();
harness.wait_for_prompt().unwrap();
harness
.type_text("Dev Container: Show Container Logs")
.unwrap();
harness
.wait_until(|h| {
h.screen_to_string()
.contains("Dev Container: Show Container Logs")
})
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
for _ in 0..40 {
harness.tick_and_render().unwrap();
std::thread::sleep(Duration::from_millis(25));
harness.advance_time(Duration::from_millis(25));
}
let screen = harness.screen_to_string();
assert!(
screen.contains("*Dev Container*"),
"*Dev Container* (info) buffer must still be visible in \
the tab bar after Show Container Logs ran. Screen:\n{screen}"
);
assert!(
screen.contains("*Dev Container Logs*"),
"*Dev Container Logs* buffer must be visible in the \
tab bar — most recent Show command. Screen:\n{screen}"
);
}
#[cfg(unix)]
#[test]
fn show_container_logs_buffer_has_line_wrap_disabled() {
let (_temp, workspace) = set_up_workspace();
let mut harness = EditorTestHarness::create(
160,
40,
HarnessOptions::new()
.with_working_dir(workspace.clone())
.with_fake_devcontainer(),
)
.unwrap();
harness.tick_and_render().unwrap();
attach_via_fake(&mut harness);
harness
.wait_until(|h| h.screen_to_string().contains("devcontainer-logs/build-"))
.unwrap();
harness
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
.unwrap();
harness.wait_for_prompt().unwrap();
harness
.type_text("Dev Container: Show Container Logs")
.unwrap();
harness
.wait_until(|h| {
h.screen_to_string()
.contains("Dev Container: Show Container Logs")
})
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
for _ in 0..40 {
harness.tick_and_render().unwrap();
std::thread::sleep(Duration::from_millis(25));
harness.advance_time(Duration::from_millis(25));
}
let screen = harness.screen_to_string();
assert!(
screen.contains("*Dev Container Logs*"),
"Show Container Logs must surface its named panel buffer. \
Screen:\n{screen}"
);
let logs_section = screen
.lines()
.skip_while(|l| !l.contains("*Dev Container Logs*"))
.take(10)
.collect::<Vec<_>>()
.join("\n");
assert!(
!logs_section.contains('\u{21B5}'),
"panel logs buffer should default to line-wrap off — \
wrap marker '↵' must not appear in the logs body. \
Section:\n{logs_section}"
);
}
#[cfg(unix)]
#[test]
fn lifecycle_command_output_lands_in_panel() {
fresh::i18n::set_locale("en");
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().canonicalize().unwrap();
let dc = workspace.join(".devcontainer");
fs::create_dir_all(&dc).unwrap();
fs::write(
dc.join("devcontainer.json"),
r#"{
"name": "lifecycle-output-test",
"image": "ubuntu:22.04",
"remoteUser": "vscode",
"postCreateCommand": "echo HELLO_FROM_LIFECYCLE_OUTPUT"
}"#,
)
.unwrap();
let plugins_dir = workspace.join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
copy_plugin_lib(&plugins_dir);
copy_plugin(&plugins_dir, "devcontainer");
let mut harness = EditorTestHarness::create(
160,
40,
HarnessOptions::new()
.with_working_dir(workspace.clone())
.with_fake_devcontainer(),
)
.unwrap();
harness.tick_and_render().unwrap();
attach_via_fake(&mut harness);
harness
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
.unwrap();
harness.wait_for_prompt().unwrap();
harness.type_text("Dev Container: Run Lifecycle").unwrap();
harness
.wait_until(|h| {
h.screen_to_string()
.contains("Dev Container: Run Lifecycle Command")
})
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness
.wait_until(|h| h.screen_to_string().contains("postCreateCommand"))
.unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness
.wait_until(|h| h.screen_to_string().contains("HELLO_FROM_LIFECYCLE_OUTPUT"))
.unwrap();
}