Compare commits

...

23 Commits

Author SHA1 Message Date
starr-openai
bf36c3589c codex: preserve startup failure test cwd allow after rebase
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:58:45 -07:00
starr-openai
0e9b65531c codex: keep startup failure coverage on windows
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:53:05 -07:00
starr-openai
4da2285b22 codex: fix CI failure on PR #22200
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:53:05 -07:00
starr-openai
42294865a9 codex: fix CI failure on PR #22200
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:53:05 -07:00
starr-openai
3548e1f7b1 codex: fix CI failure on PR #22200
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:53:05 -07:00
starr-openai
1165e651fe codex: address PR review feedback (#22200)
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:53:05 -07:00
starr-openai
bf623de44b codex: fix CI failure on PR #22200
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:53:04 -07:00
starr-openai
e0cda247aa codex: preserve declined unified exec startup end
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:53:04 -07:00
starr-openai
78cdba69c2 codex: address PR review feedback (#22200)
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:53:04 -07:00
starr-openai
e0554f5167 codex: candidate A minimal unified exec startup fix
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:53:04 -07:00
starr-openai
d19daff01d Emit unified exec end on startup failure
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:53:04 -07:00
starr-openai
aaefec794f codex: preserve TurnContext cwd deprecation allow after rebase
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:53:00 -07:00
starr-openai
c324297099 codex: fix CI failure on PR #22201
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:48:04 -07:00
starr-openai
f719c0aa49 Use remote fs for turn diff repo root
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:48:03 -07:00
starr-openai
0134886aa8 codex: fix CI failure on PR #22199
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:46:49 -07:00
starr-openai
20a5b436c7 codex: fix CI failure on PR #22199
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:46:49 -07:00
starr-openai
bddaabf885 codex: fix CI failure on PR #22199
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:46:48 -07:00
starr-openai
4473690d54 codex: fix CI failure on PR #22199
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:46:48 -07:00
starr-openai
9cd7251919 codex: fix CI failure on PR #22199
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:46:48 -07:00
starr-openai
c98047f9b8 codex: fix CI failure on PR #22199
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:46:48 -07:00
starr-openai
35217f2dd9 codex: fix CI failure on PR #22199
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:46:48 -07:00
starr-openai
a6bd9c46de codex: fix CI failure on PR #22199
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:46:48 -07:00
starr-openai
3c4b381235 Isolate tmp-dependent tests from ambient git
Co-authored-by: Codex <noreply@openai.com>
2026-05-13 11:46:48 -07:00
13 changed files with 362 additions and 82 deletions

View File

@@ -1726,16 +1726,49 @@ async fn non_git_repo_skills_search_does_not_walk_parents() {
fs::create_dir_all(&nested_dir).unwrap();
write_skill_at(
&outer_dir
.path()
.join(REPO_ROOT_CONFIG_DIR_NAME)
.join(SKILLS_DIR_NAME),
&outer_dir.path().join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME),
"outer",
"outer-skill",
"from outer",
);
let cfg = make_config_for_cwd(&codex_home, nested_dir).await;
let mut system_config = toml::map::Map::new();
system_config.insert(
"project_root_markers".to_string(),
TomlValue::Array(vec![TomlValue::String(
"__codex_test_project_root_marker_that_does_not_exist__".to_string(),
)]),
);
let user_config_path = codex_home.path().join(CONFIG_TOML_FILE);
let system_config_path = codex_home.path().join("etc/codex/config.toml");
fs::create_dir_all(
system_config_path
.parent()
.expect("system config path should have a parent"),
)
.expect("create fake system config dir");
let cfg = TestConfig {
cwd: nested_dir.abs(),
config_layer_stack: ConfigLayerStack::new(
vec![
ConfigLayerEntry::new(
ConfigLayerSource::System {
file: config_file(system_config_path),
},
TomlValue::Table(system_config),
),
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: config_file(user_config_path),
},
TomlValue::Table(toml::map::Map::new()),
),
],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("valid config layer stack"),
};
let outcome = load_skills_for_test(&cfg).await;
assert!(

View File

@@ -1,5 +1,6 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::Ordering;
@@ -67,8 +68,10 @@ use codex_analytics::InvocationType;
use codex_analytics::TurnResolvedConfigFact;
use codex_analytics::build_track_events_context;
use codex_async_utils::OrCancelExt;
use codex_exec_server::ExecutorFileSystem;
use codex_features::Feature;
use codex_git_utils::get_git_repo_root;
use codex_git_utils::get_git_repo_root_with_fs;
use codex_hooks::HookEvent;
use codex_hooks::HookEventAfterAgent;
use codex_hooks::HookPayload;
@@ -100,6 +103,7 @@ use codex_protocol::protocol::WarningEvent;
use codex_protocol::user_input::UserInput;
use codex_tools::ToolName;
use codex_tools::filter_request_plugin_install_discoverable_tools_for_client;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_stream_parser::AssistantTextChunk;
use codex_utils_stream_parser::AssistantTextStreamParser;
use codex_utils_stream_parser::ProposedPlanSegment;
@@ -367,8 +371,11 @@ pub(crate) async fn run_turn(
// Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains
// many turns, from the perspective of the user, it is a single turn.
#[allow(deprecated)]
let display_root = get_git_repo_root(turn_context.cwd.as_path())
.unwrap_or_else(|| turn_context.cwd.clone().into_path_buf());
let display_root = turn_diff_display_root(
&turn_context.cwd,
turn_context.environments.primary_filesystem(),
)
.await;
let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::with_display_root(
display_root,
)));
@@ -2262,6 +2269,19 @@ async fn try_run_sampling_request(
outcome
}
async fn turn_diff_display_root(
cwd: &AbsolutePathBuf,
fs: Option<Arc<dyn ExecutorFileSystem>>,
) -> PathBuf {
match fs {
Some(fs) => get_git_repo_root_with_fs(fs.as_ref(), cwd)
.await
.map(AbsolutePathBuf::into_path_buf),
None => get_git_repo_root(cwd.as_path()),
}
.unwrap_or_else(|| cwd.clone().into_path_buf())
}
pub(crate) fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) -> Option<String> {
for item in responses.iter().rev() {
if let Some(message) = last_assistant_message_from_item(item, /*plan_mode*/ false) {
@@ -2270,3 +2290,29 @@ pub(crate) fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) -
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use codex_exec_server::LOCAL_FS;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn turn_diff_display_root_uses_selected_filesystem_or_local_fallback() {
let repo = tempfile::tempdir().expect("repo tempdir");
std::fs::create_dir(repo.path().join(".git")).expect("create git dir");
let nested = repo.path().join("nested");
std::fs::create_dir(&nested).expect("create nested dir");
let nested = AbsolutePathBuf::try_from(nested).expect("absolute nested path");
let repo_root = repo.path().to_path_buf();
assert_eq!(
turn_diff_display_root(&nested, Some(Arc::clone(&LOCAL_FS))).await,
repo_root
);
assert_eq!(
turn_diff_display_root(&nested, /*fs*/ None).await,
repo_root
);
}
}

View File

@@ -280,7 +280,11 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode {
let command =
build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone())
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
.map_err(|_| {
ToolError::Codex(CodexErr::InvalidRequest(
"missing command line for PTY".into(),
))
})?;
let options = unified_exec_options(attempt.network_denial_cancellation_token.clone());
let mut exec_env = attempt
.env_for(command, options, managed_network)
@@ -319,7 +323,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
network_policy_decision: None,
}))
}
other => ToolError::Rejected(other.to_string()),
other => ToolError::Codex(CodexErr::Fatal(other.to_string())),
});
}
None => {
@@ -331,7 +335,11 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
}
let command =
build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone())
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
.map_err(|_| {
ToolError::Codex(CodexErr::InvalidRequest(
"missing command line for PTY".into(),
))
})?;
let options = unified_exec_options(attempt.network_denial_cancellation_token.clone());
let mut exec_env = attempt
.env_for(command, options, managed_network)
@@ -353,7 +361,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
network_policy_decision: None,
}))
}
other => ToolError::Rejected(other.to_string()),
other => ToolError::Codex(CodexErr::Fatal(other.to_string())),
})
}
}

View File

@@ -18,6 +18,8 @@ pub(crate) enum UnifiedExecError {
StdinClosed,
#[error("missing command line for unified exec request")]
MissingCommandLine,
#[error("{message}")]
Rejected { message: String },
#[error("Command denied by sandbox: {message}")]
SandboxDenied {
message: String,
@@ -34,6 +36,10 @@ impl UnifiedExecError {
Self::ProcessFailed { message }
}
pub(crate) fn rejected(message: String) -> Self {
Self::Rejected { message }
}
pub(crate) fn sandbox_denied(message: String, output: ExecToolCallOutput) -> Self {
Self::SandboxDenied { message, output }
}

View File

@@ -19,6 +19,7 @@ use crate::sandboxing::ExecServerEnvConfig;
use crate::tools::context::ExecCommandToolOutput;
use crate::tools::events::ToolEmitter;
use crate::tools::events::ToolEventCtx;
use crate::tools::events::ToolEventFailure;
use crate::tools::events::ToolEventStage;
use crate::tools::network_approval::DeferredNetworkApproval;
use crate::tools::network_approval::finish_deferred_network_approval;
@@ -372,27 +373,6 @@ impl UnifiedExecProcessManager {
context: &UnifiedExecContext,
) -> Result<ExecCommandToolOutput, UnifiedExecError> {
let cwd = request.cwd.clone();
let process = self
.open_session_with_sandbox(&request, cwd.clone(), context)
.await;
let (process, mut deferred_network_approval) = match process {
Ok((process, deferred_network_approval)) => {
(Arc::new(process), deferred_network_approval)
}
Err(err) => {
self.release_process_id(request.process_id).await;
return Err(err);
}
};
if let Some(deferred) = deferred_network_approval.as_ref() {
terminate_process_on_network_denial(
Arc::clone(&process),
Arc::downgrade(&context.session),
deferred.clone(),
);
}
let transcript = Arc::new(tokio::sync::Mutex::new(HeadTailBuffer::default()));
let event_ctx = ToolEventCtx::new(
context.session.as_ref(),
@@ -406,7 +386,42 @@ impl UnifiedExecProcessManager {
ExecCommandSource::UnifiedExecStartup,
Some(request.process_id.to_string()),
);
let process = self
.open_session_with_sandbox(&request, cwd.clone(), context)
.await;
let (process, mut deferred_network_approval) = match process {
Ok((process, deferred_network_approval)) => {
(Arc::new(process), deferred_network_approval)
}
Err(err) => {
emitter.emit(event_ctx, ToolEventStage::Begin).await;
let failure = match &err {
UnifiedExecError::Rejected { message } => ToolEventFailure::Rejected {
message: message.clone(),
applied_patch_delta: None,
},
UnifiedExecError::SandboxDenied { output, .. } => {
ToolEventFailure::Output(output.clone())
}
_ => ToolEventFailure::Message(format!("execution error: {err:?}")),
};
emitter
.emit(event_ctx, ToolEventStage::Failure(failure))
.await;
self.release_process_id(request.process_id).await;
return Err(err);
}
};
emitter.emit(event_ctx, ToolEventStage::Begin).await;
if let Some(deferred) = deferred_network_approval.as_ref() {
terminate_process_on_network_denial(
Arc::clone(&process),
Arc::downgrade(&context.session),
deferred.clone(),
);
}
start_streaming_output(&process, context, Arc::clone(&transcript));
let start = Instant::now();
@@ -1085,6 +1100,7 @@ impl UnifiedExecProcessManager {
};
UnifiedExecError::sandbox_denied(message, output)
}
ToolError::Rejected(message) => UnifiedExecError::rejected(message),
other => UnifiedExecError::create_process(format!("{other:?}")),
})
}

View File

@@ -231,6 +231,73 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() {
);
}
#[tokio::test]
async fn startup_failure_emits_begin_then_failed_end() {
let (session, turn, rx_event) = crate::session::tests::make_session_and_context_with_rx().await;
let context = UnifiedExecContext::new(
Arc::clone(&session),
Arc::clone(&turn),
"call-unified-startup-failure".to_string(),
);
let process_id = session
.services
.unified_exec_manager
.allocate_process_id()
.await;
let invalid_remote = Arc::new(
codex_exec_server::Environment::create_for_tests(Some("not-a-websocket-url".to_string()))
.expect("remote test environment"),
);
#[allow(deprecated)]
let request = ExecCommandRequest {
command: vec!["echo".to_string(), "hello".to_string()],
hook_command: String::new(),
process_id,
yield_time_ms: 1000,
max_output_tokens: None,
cwd: turn.cwd.clone(),
sandbox_cwd: turn.cwd.clone(),
environment: invalid_remote,
network: None,
tty: true,
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
additional_permissions_preapproved: false,
justification: None,
prefix_rule: None,
};
let err = session
.services
.unified_exec_manager
.exec_command(request, &context)
.await
.expect_err("unreachable remote exec-server should fail startup");
assert!(matches!(err, UnifiedExecError::CreateProcess { .. }));
let begin_event = tokio::time::timeout(Duration::from_secs(1), rx_event.recv())
.await
.expect("timed out waiting for begin event")
.expect("event channel closed");
let codex_protocol::protocol::EventMsg::ExecCommandBegin(begin_event) = begin_event.msg else {
panic!("expected ExecCommandBegin event");
};
assert_eq!(begin_event.call_id, "call-unified-startup-failure");
let end_event = tokio::time::timeout(Duration::from_secs(1), rx_event.recv())
.await
.expect("timed out waiting for end event")
.expect("event channel closed");
let codex_protocol::protocol::EventMsg::ExecCommandEnd(end_event) = end_event.msg else {
panic!("expected ExecCommandEnd event");
};
assert_eq!(end_event.call_id, "call-unified-startup-failure");
assert_eq!(
end_event.status,
codex_protocol::protocol::ExecCommandStatus::Failed
);
}
#[test]
fn pruning_prefers_exited_processes_outside_recently_used() {
let now = Instant::now();

View File

@@ -30,6 +30,7 @@ use codex_protocol::user_input::UserInput;
use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::assert_regex_match;
use core_test_support::get_remote_test_env;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
@@ -67,6 +68,15 @@ async fn apply_patch_harness_with(
Box::pin(TestCodexHarness::with_remote_aware_builder(builder)).await
}
async fn local_apply_patch_harness_with(
configure: impl FnOnce(TestCodexBuilder) -> TestCodexBuilder,
) -> Result<TestCodexHarness> {
let builder = configure(test_codex()).with_config(|config| {
config.include_apply_patch_tool = true;
});
Box::pin(TestCodexHarness::with_builder(builder)).await
}
async fn submit_without_wait(harness: &TestCodexHarness, prompt: &str) -> Result<()> {
submit_without_wait_with_turn_permissions(
harness,
@@ -1358,40 +1368,9 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nested() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = apply_patch_harness_with(|builder| {
builder
.with_model("gpt-5.4")
.with_config(|config| {
config.cwd = config.cwd.join("subdir");
})
.with_workspace_setup(|cwd, fs| async move {
fs.create_directory(
&cwd,
CreateDirectoryOptions { recursive: true },
/*sandbox*/ None,
)
.await?;
let repo_root = cwd.parent().expect("nested cwd should have parent");
fs.write_file(
&repo_root.join(".git"),
b"gitdir: /tmp/fake-worktree\n".to_vec(),
/*sandbox*/ None,
)
.await?;
fs.write_file(
&repo_root.join("repo.txt"),
b"before\n".to_vec(),
/*sandbox*/ None,
)
.await?;
Ok(())
})
})
.await?;
async fn assert_apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nested(
harness: TestCodexHarness,
) -> Result<()> {
let test = harness.test();
let codex = test.codex.clone();
let repo_root = harness
@@ -1437,6 +1416,54 @@ async fn apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nest
Ok(())
}
fn nested_repo_relative_diff_builder(builder: TestCodexBuilder) -> TestCodexBuilder {
builder
.with_model("gpt-5.4")
.with_config(|config| {
config.cwd = config.cwd.join("subdir");
})
.with_workspace_setup(|cwd, fs| async move {
fs.create_directory(
&cwd,
CreateDirectoryOptions { recursive: true },
/*sandbox*/ None,
)
.await?;
let repo_root = cwd.parent().expect("nested cwd should have parent");
fs.write_file(
&repo_root.join(".git"),
b"gitdir: /tmp/fake-worktree\n".to_vec(),
/*sandbox*/ None,
)
.await?;
fs.write_file(
&repo_root.join("repo.txt"),
b"before\n".to_vec(),
/*sandbox*/ None,
)
.await?;
Ok(())
})
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nested() -> Result<()> {
let harness = local_apply_patch_harness_with(nested_repo_relative_diff_builder).await?;
assert_apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nested(harness).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_turn_diff_paths_stay_repo_relative_for_remote_nested_session_cwd() -> Result<()>
{
skip_if_no_network!(Ok(()));
let Some(_remote_env) = get_remote_test_env() else {
return Ok(());
};
let harness = apply_patch_harness_with(nested_repo_relative_diff_builder).await?;
assert_apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nested(harness).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> Result<()> {
skip_if_no_network!(Ok(()));

View File

@@ -38,6 +38,21 @@ pub fn get_git_repo_root(base_dir: &Path) -> Option<PathBuf> {
find_ancestor_git_entry(base).map(|(repo_root, _)| repo_root)
}
/// Return the git repository root for `base_dir` using the provided executor
/// filesystem. This is the remote-environment equivalent of [`get_git_repo_root`].
pub async fn get_git_repo_root_with_fs(
fs: &dyn ExecutorFileSystem,
base_dir: &AbsolutePathBuf,
) -> Option<AbsolutePathBuf> {
let base = match fs.get_metadata(base_dir, /*sandbox*/ None).await {
Ok(metadata) if metadata.is_directory => base_dir.clone(),
_ => base_dir.parent()?,
};
find_ancestor_git_entry_with_fs(fs, &base)
.await
.map(|(repo_root, _)| repo_root)
}
/// Timeout for git commands to prevent freezing on large repositories
const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5);

View File

@@ -31,6 +31,7 @@ pub use info::default_branch_name;
pub use info::get_git_remote_urls;
pub use info::get_git_remote_urls_assume_git_repo;
pub use info::get_git_repo_root;
pub use info::get_git_repo_root_with_fs;
pub use info::get_has_changes;
pub use info::get_head_commit_hash;
pub use info::git_diff_to_remote;

View File

@@ -188,12 +188,21 @@ mod tests {
#[test]
fn environment_id_fallback_has_cwd_prefix() {
let dir = tempfile::tempdir().expect("tempdir");
let env_id = environment_id_from_cwd(dir.path());
let canonical = dir
.path()
let unique = tempfile::Builder::new()
.prefix("codex-secrets-test-")
.tempdir()
.expect("tempdir");
let cwd = PathBuf::from(std::path::MAIN_SEPARATOR.to_string()).join(
unique
.path()
.file_name()
.expect("tempdir should have a file name"),
);
drop(unique);
let env_id = environment_id_from_cwd(&cwd);
let canonical = cwd
.canonicalize()
.expect("tempdir canonical path should exist")
.unwrap_or_else(|_| cwd.clone())
.to_string_lossy()
.into_owned();
let mut hasher = Sha256::new();

View File

@@ -1,6 +1,8 @@
use super::*;
use codex_app_server_protocol::PluginAvailability;
use pretty_assertions::assert_eq;
use std::sync::Mutex;
use std::sync::OnceLock;
pub(super) async fn test_config() -> Config {
// Start from the built-in defaults so tests do not inherit host/system config.
@@ -16,7 +18,7 @@ pub(super) async fn test_config() -> Config {
config.codex_home = codex_home.abs();
config.sqlite_home = codex_home.clone();
config.log_dir = codex_home.join("log");
config.cwd = PathBuf::from(test_path_display("/tmp/project")).abs();
config.cwd = test_project_path().abs();
config.config_layer_stack = ConfigLayerStack::default();
config.startup_warnings.clear();
config.user_instructions = None;
@@ -24,7 +26,23 @@ pub(super) async fn test_config() -> Config {
}
pub(super) fn test_project_path() -> PathBuf {
PathBuf::from(test_path_display("/tmp/project"))
let isolated_root = tempfile::Builder::new()
.prefix("chatwidget-project-")
.tempdir_in(std::env::temp_dir())
.expect("tempdir")
.keep();
let project = isolated_root.join("project");
std::fs::create_dir_all(project.join(".git")).expect("create test project git marker");
isolated_test_project_paths()
.lock()
.expect("test project path registry")
.push(project.clone());
project
}
fn isolated_test_project_paths() -> &'static Mutex<Vec<PathBuf>> {
static TEST_PROJECT_PATHS: OnceLock<Mutex<Vec<PathBuf>>> = OnceLock::new();
TEST_PROJECT_PATHS.get_or_init(|| Mutex::new(Vec::new()))
}
pub(super) fn truncated_path_variants(path: &str) -> Vec<String> {
@@ -37,6 +55,30 @@ pub(super) fn truncated_path_variants(path: &str) -> Vec<String> {
pub(super) fn normalize_snapshot_paths(text: impl Into<String>) -> String {
let mut text = text.into();
for isolated_project in isolated_test_project_paths()
.lock()
.expect("test project path registry")
.iter()
{
let isolated_project = isolated_project.to_string_lossy();
text = text.replace(isolated_project.as_ref(), "/tmp/project");
for isolated_prefix in truncated_path_variants(isolated_project.as_ref())
.into_iter()
.rev()
{
let normalized = if isolated_prefix.chars().count() >= "/tmp/project".chars().count() {
"/tmp/project".to_string()
} else {
let unix_prefix: String = "/tmp/project"
.chars()
.take(isolated_prefix.chars().count())
.collect();
format!("{unix_prefix}")
};
text = text.replace(&format!("{isolated_prefix}"), &normalized);
}
}
for unix_path in ["/tmp/project", "/tmp/hooks.json"] {
let platform_path = test_path_display(unix_path);
if platform_path != unix_path {
@@ -64,13 +106,8 @@ pub(super) fn normalize_snapshot_paths(text: impl Into<String>) -> String {
}
pub(super) fn normalized_backend_snapshot<T: std::fmt::Display>(value: &T) -> String {
let platform_test_cwd = test_path_display("/tmp/project");
let rendered = format!("{value}");
if platform_test_cwd == "/tmp/project" {
return rendered;
}
rendered
.lines()
.map(|line| {

View File

@@ -1917,10 +1917,10 @@ async fn status_line_model_with_reasoning_includes_fast_for_fast_capable_models(
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
chat.refresh_status_line();
let test_cwd = test_path_display("/tmp/project");
let test_cwd = normalize_snapshot_paths(test_path_display("/tmp/project"));
assert_eq!(
status_line_text(&chat),
status_line_text(&chat).map(normalize_snapshot_paths),
Some(format!("gpt-5.4 xhigh fast · Context 0% used · {test_cwd}"))
);
@@ -1928,7 +1928,7 @@ async fn status_line_model_with_reasoning_includes_fast_for_fast_capable_models(
chat.refresh_status_line();
assert_eq!(
status_line_text(&chat),
status_line_text(&chat).map(normalize_snapshot_paths),
Some(format!(
"gpt-5.3-codex xhigh · Context 0% used · {test_cwd}"
))

View File

@@ -105,6 +105,7 @@ async fn status_line_setup_popup_live_only_snapshot() {
#[tokio::test]
async fn status_surface_preview_lines_hardcoded_only_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
cache_project_root(&mut chat, "my-project");
let snapshot = combined_preview_snapshot(
&mut chat,
@@ -144,6 +145,7 @@ async fn thread_title_falls_back_to_thread_id_when_unnamed() {
#[tokio::test]
async fn status_line_setup_popup_hardcoded_only_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
cache_project_root(&mut chat, "my-project");
chat.config.tui_status_line = Some(vec![
"project-name".to_string(),
"git-branch".to_string(),
@@ -159,22 +161,30 @@ async fn status_line_setup_popup_hardcoded_only_snapshot() {
#[tokio::test]
async fn status_surface_preview_lines_mixed_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
cache_project_root(&mut chat, "my-project");
chat.status_line_branch = Some("feature/mixed-preview".to_string());
chat.thread_name = Some("Mixed preview thread".to_string());
let snapshot = combined_preview_snapshot(
let status_line = status_preview_line(
&mut chat,
&[
StatusLineItem::ProjectRoot,
StatusLineItem::GitBranch,
StatusLineItem::ThreadTitle,
],
);
chat.status_line_project_root_name_cache = None;
let terminal_title = title_preview_line(
&mut chat,
&[
TerminalTitleItem::Project,
TerminalTitleItem::Thread,
TerminalTitleItem::TaskProgress,
],
);
let snapshot = normalize_snapshot_paths(format!(
"status line: {status_line}\nterminal title: {terminal_title}"
));
assert_chatwidget_snapshot!("status_surface_previews_mixed", snapshot);
}
@@ -182,6 +192,7 @@ async fn status_surface_preview_lines_mixed_snapshot() {
#[tokio::test]
async fn status_line_setup_popup_mixed_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
cache_project_root(&mut chat, "my-project");
chat.status_line_branch = Some("feature/mixed-preview".to_string());
chat.thread_name = Some("Mixed preview thread".to_string());
chat.config.tui_status_line = Some(vec![
@@ -250,6 +261,10 @@ async fn terminal_title_setup_popup_mixed_snapshot() {
#[tokio::test]
async fn missing_project_root_uses_different_status_and_title_preview_sources() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.status_line_project_root_name_cache = Some(CachedProjectRootName {
cwd: chat.config.cwd.to_path_buf(),
root_name: None,
});
let status_preview = status_preview_line(&mut chat, &[StatusLineItem::ProjectRoot]);
let title_preview = title_preview_line(&mut chat, &[TerminalTitleItem::Project]);