#![cfg(feature = "plugins")]
use crate::common::harness::{copy_plugin, copy_plugin_lib, EditorTestHarness, HarnessOptions};
use crossterm::event::{KeyCode, KeyModifiers};
use std::fs;
use std::path::Path;
fn set_up_workspace() -> (tempfile::TempDir, std::path::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-e2e",
"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_attach_popup(harness: &mut EditorTestHarness) {
bounded_wait(harness, "devcontainer plugin command registration", |h| {
let reg = h.editor().command_registry().read().unwrap();
reg.get_all().iter().any(|c| c.name == "%cmd.run_lifecycle")
});
harness.editor().fire_plugins_loaded_hook();
bounded_wait(harness, "Reopen in Container popup", |h| {
let screen = h.screen_to_string();
screen.contains("Dev Container Detected") && screen.contains("Reopen in Container")
});
}
fn bounded_wait<F>(harness: &mut EditorTestHarness, what: &str, mut cond: F)
where
F: FnMut(&EditorTestHarness) -> bool,
{
let max_iters = 200;
for _ in 0..max_iters {
harness.tick_and_render().unwrap();
if cond(harness) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(50));
harness.advance_time(std::time::Duration::from_millis(50));
}
let plugin_names: Vec<_> = harness
.editor()
.plugin_manager()
.list_plugins()
.into_iter()
.map(|p| p.name)
.collect();
panic!(
"bounded_wait timed out: {what} not satisfied in {max_iters} ticks (~10s).\n\
plugins loaded: {plugin_names:?}\n\
Screen:\n{}",
harness.screen_to_string()
);
}
fn wait_for_container_authority(harness: &mut EditorTestHarness) -> String {
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 harness.editor().authority().display_label.clone();
}
if harness
.editor()
.authority()
.display_label
.starts_with("Container:")
{
return harness.editor().authority().display_label.clone();
}
std::thread::sleep(std::time::Duration::from_millis(50));
harness.advance_time(std::time::Duration::from_millis(50));
}
let plugin_names: Vec<_> = harness
.editor()
.plugin_manager()
.list_plugins()
.into_iter()
.map(|p| p.name)
.collect();
panic!(
"container authority never staged after {max_iters} ticks (~10s).\n\
current display_label: {:?}\n\
plugins loaded: {plugin_names:?}\n\
Screen:\n{}",
harness.editor().authority().display_label,
harness.screen_to_string()
);
}
#[test]
fn attach_via_fake_devcontainer_lands_container_authority() {
let (_workspace_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();
let plugin_names: Vec<_> = harness
.editor()
.plugin_manager()
.list_plugins()
.into_iter()
.map(|p| p.name)
.collect();
assert!(
plugin_names.iter().any(|n| n == "devcontainer"),
"`devcontainer` plugin must be loaded. Loaded: {:?}",
plugin_names
);
wait_for_attach_popup(&mut harness);
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
let label = wait_for_container_authority(&mut harness);
let container_id = label
.strip_prefix("Container:")
.expect("display_label starts with Container:");
assert!(
!container_id.is_empty(),
"container id must be non-empty (label = {label:?})"
);
let state = harness
.fake_devcontainer_state()
.expect("with_fake_devcontainer was set");
let last_id_path = state.join("last_id");
let last_id = fs::read_to_string(&last_id_path)
.unwrap_or_else(|e| panic!("fake CLI never wrote last_id at {last_id_path:?}: {e}"));
assert!(
last_id.trim().starts_with(container_id) || container_id.starts_with(last_id.trim()),
"authority short id {container_id:?} must match fake CLI's last_id {last_id:?}"
);
let log_dir = workspace.join(".fresh-cache").join("devcontainer-logs");
let log_count = fs::read_dir(&log_dir)
.unwrap_or_else(|e| panic!("expected build-log dir at {log_dir:?}: {e}"))
.count();
assert!(
log_count >= 1,
"expected at least one build-<ts>.log under {log_dir:?}, found {log_count}"
);
drop_workspace_temp(&workspace);
}
fn drop_workspace_temp(_workspace: &Path) {}
#[cfg(unix)]
#[test]
fn user_env_probe_capture_propagates_path_into_subsequent_execs() {
use crossterm::event::KeyCode;
let (_workspace_temp, workspace) = set_up_workspace();
let mut harness = EditorTestHarness::create(
160,
40,
HarnessOptions::new()
.with_working_dir(workspace.clone())
.with_fake_devcontainer(),
)
.unwrap();
let state = harness
.fake_devcontainer_state()
.expect("fake state present")
.to_path_buf();
fs::write(
state.join("probe_response"),
"PATH=/home/vscode/.local/bin:/usr/local/bin:/usr/bin\nHOME=/home/vscode\nLANG=C.UTF-8\n",
)
.expect("write probe_response");
harness.tick_and_render().unwrap();
wait_for_attach_popup(&mut harness);
harness
.send_key(KeyCode::Enter, crossterm::event::KeyModifiers::NONE)
.unwrap();
let _label = wait_for_container_authority(&mut harness);
let history_path = state.join("exec_history");
let history = fs::read_to_string(&history_path)
.unwrap_or_else(|e| panic!("exec_history not found at {history_path:?}: {e}"));
let probe_lines: Vec<_> = history
.lines()
.filter(|l| l.contains("bash -l -i -c env") || l.contains("bash -l -c env"))
.collect();
assert!(
!probe_lines.is_empty(),
"plugin must call `bash -lic env` to capture userEnvProbe; \
exec_history was:\n{history}"
);
let spawner = harness.editor().authority().long_running_spawner.clone();
let rt = tokio::runtime::Runtime::new().expect("tokio runtime starts");
rt.block_on(async move { spawner.command_exists("ls").await });
drop(rt);
let final_history = fs::read_to_string(&history_path).expect("history readable post-spawn");
let cmd_exists_calls: Vec<_> = final_history
.lines()
.filter(|l| l.contains("sh -c command -v ls"))
.collect();
assert!(
!cmd_exists_calls.is_empty(),
"post-attach command_exists must have run a `command -v` probe; \
final history:\n{final_history}"
);
let last = cmd_exists_calls.last().unwrap();
assert!(
last.contains("PATH=/home/vscode/.local/bin:/usr/local/bin:/usr/bin"),
"command_exists probe must include the captured PATH; \
got line: {last:?}\nfull history:\n{final_history}"
);
drop_workspace_temp(&workspace);
}
fn wait_for_failed_attach_popup(harness: &mut EditorTestHarness) {
harness
.wait_until(|h| {
let s = h.screen_to_string();
s.contains("Dev Container Attach Failed")
&& s.contains("Retry")
&& s.contains("Reopen Locally")
})
.unwrap();
}
fn accept_attach(harness: &mut EditorTestHarness) {
wait_for_attach_popup(harness);
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
}
#[test]
fn attach_failure_surfaces_failed_attach_popup() {
let (_workspace_temp, workspace) = set_up_workspace();
std::env::set_var("FAKE_DC_UP_FAIL", "1");
std::env::set_var("FAKE_DC_UP_FAIL_REASON", "image not found: bogus:latest");
let mut harness = EditorTestHarness::create(
160,
40,
HarnessOptions::new()
.with_working_dir(workspace.clone())
.with_fake_devcontainer(),
)
.unwrap();
harness.tick_and_render().unwrap();
accept_attach(&mut harness);
wait_for_failed_attach_popup(&mut harness);
assert!(
!harness
.editor()
.authority()
.display_label
.starts_with("Container:"),
"failed attach must not install a container authority; label = {:?}",
harness.editor().authority().display_label,
);
std::env::remove_var("FAKE_DC_UP_FAIL");
std::env::remove_var("FAKE_DC_UP_FAIL_REASON");
drop_workspace_temp(&workspace);
}
#[test]
fn attach_bad_json_surfaces_failed_attach_popup() {
let (_workspace_temp, workspace) = set_up_workspace();
std::env::set_var("FAKE_DC_UP_BAD_JSON", "1");
let mut harness = EditorTestHarness::create(
160,
40,
HarnessOptions::new()
.with_working_dir(workspace.clone())
.with_fake_devcontainer(),
)
.unwrap();
harness.tick_and_render().unwrap();
accept_attach(&mut harness);
wait_for_failed_attach_popup(&mut harness);
assert!(
!harness
.editor()
.authority()
.display_label
.starts_with("Container:"),
"bad-JSON failure must not install a container authority"
);
std::env::remove_var("FAKE_DC_UP_BAD_JSON");
drop_workspace_temp(&workspace);
}
#[test]
fn attach_closes_stale_build_log_buffer_from_previous_run() {
let (_workspace_temp, workspace) = set_up_workspace();
let stale_dir = workspace.join(".fresh-cache").join("devcontainer-logs");
std::fs::create_dir_all(&stale_dir).unwrap();
let stale_log = stale_dir.join("build-2026-01-01_00-00-00.log");
std::fs::write(
&stale_log,
"[+] Building 0.0s ... (from a previous attach, restored on cold start)\n",
)
.unwrap();
let mut harness = EditorTestHarness::create(
160,
40,
HarnessOptions::new()
.with_working_dir(workspace.clone())
.with_fake_devcontainer(),
)
.unwrap();
harness.tick_and_render().unwrap();
harness.open_file(&stale_log).unwrap();
assert!(
snapshot_has_buffer_at(&harness, &stale_log),
"test setup: stale log must be open as a buffer before attach.\n\
buffers: {:?}",
snapshot_buffer_paths(&harness)
);
accept_attach(&mut harness);
let _ = wait_for_container_authority(&mut harness);
harness.tick_and_render().unwrap();
let paths_after = snapshot_buffer_paths(&harness);
assert!(
!paths_after.iter().any(|p| p == &stale_log),
"F1 regression: stale build-log buffer at {stale_log:?} must be \
closed when a new attach starts. Buffers after attach: {paths_after:?}"
);
let fresh = paths_after
.iter()
.find(|p| p.starts_with(&stale_dir) && **p != stale_log);
assert!(
fresh.is_some(),
"expected at least one fresh build-log buffer under {stale_dir:?} \
(different from {stale_log:?}). Buffers: {paths_after:?}"
);
}
fn snapshot_buffer_paths(harness: &EditorTestHarness) -> Vec<std::path::PathBuf> {
let handle = harness
.editor()
.plugin_manager()
.state_snapshot_handle()
.expect("plugin manager must have a state snapshot in plugins-feature builds");
let snap = handle.read().unwrap();
snap.buffers
.values()
.filter_map(|b| b.path.clone())
.collect()
}
fn snapshot_has_buffer_at(harness: &EditorTestHarness, path: &Path) -> bool {
snapshot_buffer_paths(harness).iter().any(|p| p == path)
}
#[test]
fn attach_decision_persists_in_plugin_global_state() {
let (_workspace_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();
accept_attach(&mut harness);
let _ = wait_for_container_authority(&mut harness);
harness.tick_and_render().unwrap();
let workspace_state = harness.editor().capture_workspace();
let dc_state = workspace_state
.plugin_global_state
.get("devcontainer")
.unwrap_or_else(|| {
panic!(
"expected `devcontainer` plugin to have written global state. \
Plugin map: {:?}",
workspace_state
.plugin_global_state
.keys()
.collect::<Vec<_>>()
)
});
let key = format!("attach:{}", workspace.display());
let value = dc_state.get(&key).unwrap_or_else(|| {
panic!(
"expected key {key:?} in devcontainer plugin state. \
Keys present: {:?}",
dc_state.keys().collect::<Vec<_>>()
)
});
assert_eq!(
value.as_str(),
Some("attached"),
"attach decision must be \"attached\" after a successful \
Reopen-in-Container; got {value:?}"
);
}
#[test]
fn attach_popup_offers_separate_once_and_always_dismiss() {
use crossterm::event::{KeyCode, KeyModifiers};
let (_workspace_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();
wait_for_attach_popup(&mut harness);
let screen = harness.screen_to_string();
assert!(
screen.contains("Ignore (once)"),
"popup must offer session-only dismiss option. Screen:\n{screen}"
);
assert!(
screen.contains("Ignore (always"),
"popup must offer permanent dismiss option. Screen:\n{screen}"
);
harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.tick_and_render().unwrap();
let workspace_state = harness.editor().capture_workspace();
let dc_state = workspace_state.plugin_global_state.get("devcontainer");
let key = format!("attach:{}", workspace.display());
let persisted = dc_state.and_then(|m| m.get(&key));
assert!(
persisted.is_none() || persisted.and_then(|v| v.as_str()) != Some("dismissed"),
"Ignore (once) must NOT persist `dismissed` to plugin global state. \
Got: {persisted:?}"
);
}
#[test]
fn attach_popup_dismiss_always_persists_decision() {
use crossterm::event::{KeyCode, KeyModifiers};
let (_workspace_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();
wait_for_attach_popup(&mut harness);
harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap();
harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.tick_and_render().unwrap();
let workspace_state = harness.editor().capture_workspace();
let dc_state = workspace_state
.plugin_global_state
.get("devcontainer")
.unwrap_or_else(|| {
panic!(
"Ignore (always …) must write to plugin global state. \
Plugin map: {:?}",
workspace_state
.plugin_global_state
.keys()
.collect::<Vec<_>>()
)
});
let key = format!("attach:{}", workspace.display());
let value = dc_state.get(&key).unwrap_or_else(|| {
panic!(
"expected key {key:?} after Ignore (always). Keys present: {:?}",
dc_state.keys().collect::<Vec<_>>()
)
});
assert_eq!(
value.as_str(),
Some("dismissed"),
"Ignore (always …) must persist as \"dismissed\"; got {value:?}"
);
}
#[test]
fn attach_missing_container_id_surfaces_failed_attach_popup() {
let (_workspace_temp, workspace) = set_up_workspace();
std::env::set_var("FAKE_DC_UP_NO_CONTAINER_ID", "1");
let mut harness = EditorTestHarness::create(
160,
40,
HarnessOptions::new()
.with_working_dir(workspace.clone())
.with_fake_devcontainer(),
)
.unwrap();
harness.tick_and_render().unwrap();
accept_attach(&mut harness);
wait_for_failed_attach_popup(&mut harness);
assert!(
!harness
.editor()
.authority()
.display_label
.starts_with("Container:"),
"missing-containerId failure must not install a container authority"
);
std::env::remove_var("FAKE_DC_UP_NO_CONTAINER_ID");
drop_workspace_temp(&workspace);
}