diff --git a/crates/ropy-core/src/repository/clipboard_files.rs b/crates/ropy-core/src/repository/clipboard_files.rs index 4c2b201..a24f53a 100644 --- a/crates/ropy-core/src/repository/clipboard_files.rs +++ b/crates/ropy-core/src/repository/clipboard_files.rs @@ -36,17 +36,22 @@ fn normalize_file_path(path: &str) -> Option { return None; } - let uri_path = path - .strip_prefix("file://localhost") - .or_else(|| path.strip_prefix("file://")); - - // Percent escapes belong to URIs; decoding a filesystem path changes - // literal filenames and makes repeated normalization destructive. - // Whitespace in a nonempty filesystem path is part of its identity. - Some(uri_path.map_or_else(|| path.to_string(), decode_percent_encoded)) + // Percent escapes belong to URIs; whitespace and percent characters in a + // filesystem path are part of its identity. + let Some(uri) = path.strip_prefix("file://") else { + return Some(path.to_string()); + }; + let path_start = uri.find('/')?; + let authority = &uri[..path_start]; + if !authority.is_empty() && !authority.eq_ignore_ascii_case("localhost") { + // A remote URI cannot be treated as a relative path on this machine. + return None; + } + Some(decode_percent_encoded(&uri[path_start..])) } -/// Normalize file URIs while preserving literal filesystem percent characters. +/// Decode local file URIs, ignoring remote authorities and blank entries. +/// Literal filesystem whitespace and percent characters are preserved. #[must_use] pub fn normalize_file_paths(paths: &[String]) -> Vec { paths @@ -124,11 +129,28 @@ mod tests { let paths = vec![ "file:///tmp/hello%20world.txt".to_string(), "file://localhost/tmp/demo.txt".to_string(), + "file://LOCALHOST/tmp/case.txt".to_string(), ]; let normalized = normalize_file_paths(&paths); - assert_eq!(normalized, vec!["/tmp/hello world.txt", "/tmp/demo.txt"]); + assert_eq!( + normalized, + vec!["/tmp/hello world.txt", "/tmp/demo.txt", "/tmp/case.txt"] + ); + } + + #[rstest::rstest] + #[case("file://server/share/report.txt")] + #[case("file://localhost.example/tmp/report.txt")] + #[case("file://localhost")] + #[case("file://relative")] + fn test_normalize_file_paths_unsupported_authority_is_not_a_local_path(#[case] uri: &str) { + let paths = vec![uri.to_string()]; + assert_eq!(normalize_file_paths(&paths), Vec::::new()); + let serialized = serde_json::to_string(&paths) + .unwrap_or_else(|error| panic!("serialize fixture: {error}")); + assert_eq!(deserialize_file_paths(&serialized), Vec::::new()); } #[test] diff --git a/src/gui/board.rs b/src/gui/board.rs index 390fd1e..51232cd 100644 --- a/src/gui/board.rs +++ b/src/gui/board.rs @@ -357,12 +357,27 @@ impl RopyBoard { // Input and selection controls retain window-bound subscriptions. Rebuild them // when a Wayland surface is reopened; keep history, tasks and update state alive. pub(crate) fn attach_window(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) { + // Native registration can outlive a Wayland surface. Keep its draft and + // submission guard until completion, even as window-bound inputs change. + let pending_hotkey_save = self + .settings_editor + .hotkey + .saving + .then(|| self.resolve_activation_key_input(cx)); self.window_activation_subscription = Self::observe_activation(window, cx); self.window_appearance_subscription = Self::observe_appearance(window, cx); self.theme_catalog_subscription = Self::observe_themes(window, cx); self.search_input = cx.new(|cx| InputState::new(window, cx)); self.search_input_subscription = Self::observe_search(&self.search_input, cx); self.settings_editor = SettingsEditor::new(window, cx); + if let Some(candidate) = pending_hotkey_save { + self.settings_editor.hotkey.saving = true; + self.settings_editor.pending_hotkey.clone_from(&candidate); + self.settings_editor + .hotkey_before_recording + .clone_from(&candidate); + self.sync_activation_key_input_from_candidate(&candidate, window, cx); + } if let Some(draft) = self .theme_workspace .as_mut() diff --git a/src/gui/board/records_list/row.rs b/src/gui/board/records_list/row.rs index 4f2649e..564fef8 100644 --- a/src/gui/board/records_list/row.rs +++ b/src/gui/board/records_list/row.rs @@ -202,35 +202,6 @@ fn render_file_badge(badge_background: gpui_kit::Hsla, cx: &App) -> gpui_kit::Di .child(Icon::empty().path("icons/filter-files.svg").size(px(12.0))) } -fn create_preview( - content_type: &ContentType, - record_content: &str, - window: &Window, - cx: &mut App, -) -> AnyView { - match content_type { - ContentType::Image => preview::image_tooltip(record_content, window, cx), - ContentType::FilePath => preview::simple_tooltip( - truncate( - &file_preview_content(record_content), - TOOLTIP_CONTENT_PREVIEW_LIMIT, - TruncateOptions::with_max_lines(TOOLTIP_CONTENT_PREVIEW_MAX_LINES), - ), - window, - cx, - ), - ContentType::Text | ContentType::RichText => preview::simple_tooltip( - truncate( - record_content, - TOOLTIP_CONTENT_PREVIEW_LIMIT, - TruncateOptions::with_max_lines(TOOLTIP_CONTENT_PREVIEW_MAX_LINES), - ), - window, - cx, - ), - } -} - struct PreviewData { content_type: ContentType, record_content: String, @@ -238,14 +209,30 @@ struct PreviewData { impl PreviewData { fn new(record: &ClipboardRecord) -> Self { + let truncate_preview = |content: &str| { + truncate( + content, + TOOLTIP_CONTENT_PREVIEW_LIMIT, + TruncateOptions::with_max_lines(TOOLTIP_CONTENT_PREVIEW_MAX_LINES), + ) + }; + let record_content = match record.content_type { + ContentType::Image => record.content.clone(), + ContentType::FilePath => truncate_preview(&file_preview_content(&record.content)), + ContentType::Text | ContentType::RichText => truncate_preview(&record.content), + }; Self { content_type: record.content_type.clone(), - record_content: record.content.clone(), + record_content, } } fn build(&self, window: &Window, cx: &mut App) -> AnyView { - create_preview(&self.content_type, &self.record_content, window, cx) + if self.content_type == ContentType::Image { + preview::image_tooltip(&self.record_content, window, cx) + } else { + preview::simple_tooltip(&self.record_content, window, cx) + } } } @@ -367,17 +354,16 @@ impl RecordsListState { } fn render_list_row(&self, index: usize, window: &Window, cx: &mut App) -> AnyElement { - let Some(record) = self.record_for_filtered_index(index) else { + let records = read_or_recover(&self.records); + let Some(record_index) = self.filtered_record_indices.get(index).copied() else { return div().into_any_element(); }; - - render_list_item(&self.render_context(index, &record), window, cx) - } - - fn record_for_filtered_index(&self, filtered_index: usize) -> Option { - let record_index = self.filtered_record_indices.get(filtered_index).copied()?; - let guard = read_or_recover(&self.records); - guard.get(record_index).cloned() + let Some(record) = records.get(record_index) else { + return div().into_any_element(); + }; + let item = render_list_item(&self.render_context(index, record), window, cx); + drop(records); + item } pub(super) fn render_context<'a>( @@ -418,12 +404,7 @@ impl RecordsListState { } } -fn render_record_body( - ctx: &RenderContext<'_>, - preview_data: &PreviewData, - styles: &ItemStyle, - cx: &App, -) -> AnyElement { +fn render_record_body(ctx: &RenderContext<'_>, styles: &ItemStyle, cx: &App) -> AnyElement { let compact = ctx.layout_mode == LayoutMode::Grid; let mut content = div() .flex_1() @@ -431,11 +412,8 @@ fn render_record_body( .id(("record-content", ctx.record.id)); if !ctx.flags.preview_visible() && ctx.flags.hover_preview_enabled() { - let preview_content_type = preview_data.content_type.clone(); - let preview_record_content = preview_data.record_content.clone(); - content = content.tooltip(move |window, cx| { - create_preview(&preview_content_type, &preview_record_content, window, cx) - }); + let preview_data = PreviewData::new(ctx.record); + content = content.tooltip(move |window, cx| preview_data.build(window, cx)); } if compact { @@ -735,7 +713,6 @@ pub(super) fn render_list_item_with_grid_height( grid_height_override: Option, ) -> AnyElement { let compact = ctx.layout_mode == LayoutMode::Grid; - let preview_data = PreviewData::new(ctx.record); let styles = ItemStyle::from_app(cx); let card = if compact { @@ -755,7 +732,7 @@ pub(super) fn render_list_item_with_grid_height( .w_full() .gap_1() .child(render_grid_record_header(ctx, &styles, cx)) - .child(render_record_body(ctx, &preview_data, &styles, cx)), + .child(render_record_body(ctx, &styles, cx)), ), ctx, &styles, @@ -767,7 +744,7 @@ pub(super) fn render_list_item_with_grid_height( .justify_between() .items_start() .gap_2() - .child(render_record_body(ctx, &preview_data, &styles, cx)) + .child(render_record_body(ctx, &styles, cx)) .child(render_record_actions(ctx, cx)), ), ctx, @@ -782,7 +759,11 @@ pub(super) fn render_list_item_with_grid_height( }; if should_render_selected_preview(ctx.flags) { - item = item.child(render_selected_preview(&preview_data, window, cx)); + item = item.child(render_selected_preview( + &PreviewData::new(ctx.record), + window, + cx, + )); } item.into_any_element() @@ -808,6 +789,50 @@ mod tests { } } + #[test] + fn test_preview_data_long_text_retains_only_visible_unicode_prefix() { + let record = crate::repository::ClipboardRecord::new( + 1, + "界".repeat(1_000_000), + chrono::Local::now(), + crate::repository::ContentType::Text, + ); + let preview = super::PreviewData::new(&record); + let expected = format!("{}...", "界".repeat(500)); + assert!( + preview.record_content == expected, + "preview retained {} bytes", + preview.record_content.len() + ); + } + + #[rstest::rstest] + #[case(crate::repository::ContentType::Text, "short\ntext", "short\ntext")] + #[case( + crate::repository::ContentType::RichText, + "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11", + "1\n2\n3\n4\n5\n6\n7\n8\n9\n10..." + )] + #[case( + crate::repository::ContentType::FilePath, + r#"["file:///tmp/a%20b","/tmp/literal%20"]"#, + "/tmp/a b\n/tmp/literal%20" + )] + #[case( + crate::repository::ContentType::Image, + "/tmp/image.png", + "/tmp/image.png" + )] + fn test_preview_data_preserves_each_payload_presentation( + #[case] kind: crate::repository::ContentType, + #[case] content: &str, + #[case] expected: &str, + ) { + let record = + crate::repository::ClipboardRecord::new(1, content.into(), chrono::Local::now(), kind); + assert_eq!(super::PreviewData::new(&record).record_content, expected); + } + #[test] fn selected_preview_remains_available_when_hover_preview_is_disabled() { assert!(should_render_selected_preview(flags( diff --git a/src/gui/board/settings_handler/hotkey.rs b/src/gui/board/settings_handler/hotkey.rs index dc8aade..77d1076 100644 --- a/src/gui/board/settings_handler/hotkey.rs +++ b/src/gui/board/settings_handler/hotkey.rs @@ -141,6 +141,8 @@ impl RopyBoard { return; } }; + let previous_hotkey = + GlobalSettings::read(cx, |settings| settings.hotkey.activation_key.clone()); self.settings_editor.hotkey.saving = true; cx.notify(); cx.spawn_in(window, async move |this, cx| { @@ -148,20 +150,48 @@ impl RopyBoard { .recv() .await .unwrap_or(Err(HotkeyUpdateError::Disconnected)); - let _ = this.update_in(cx, |board, window, cx| { + // The board outlives its native popup; release the guard even if + // there is no current window for the visual completion below. + let _ = this.update(cx, |board, cx| { board.settings_editor.hotkey.saving = false; + cx.notify(); + }); + let _ = this.update_in(cx, |board, window, cx| { match result { Ok(()) => { - board.settings_editor.hotkey.recording = false; - board - .settings_editor - .pending_hotkey - .clone_from(&activation_key); - board + // Persistence already published the new live hotkey. Resolve + // against the old value to detect edits made while waiting. + let input = board .settings_editor - .hotkey_before_recording - .clone_from(&activation_key); - board.sync_activation_key_input("", &activation_key, window, cx); + .settings_activation_key_input + .read(cx) + .value(); + let candidate = Self::resolve_hotkey_candidate_input( + &input, + &board.settings_editor.pending_hotkey, + &previous_hotkey, + ); + if !board.settings_editor.hotkey.recording && candidate == activation_key { + board + .settings_editor + .pending_hotkey + .clone_from(&activation_key); + board + .settings_editor + .hotkey_before_recording + .clone_from(&activation_key); + board.sync_activation_key_input("", &activation_key, window, cx); + } else { + if !input.is_empty() + && board.settings_editor.pending_hotkey == previous_hotkey + { + board + .settings_editor + .pending_hotkey + .clone_from(&activation_key); + } + board.refresh_activation_key_placeholder(window, cx); + } Self::notify_settings_success(window, cx, t!("settings_save_success")); } Err(error) => Self::notify_hotkey_update_failed(&error, window, cx), diff --git a/src/gui/hotkey.rs b/src/gui/hotkey.rs index 4c89523..b22a352 100644 --- a/src/gui/hotkey.rs +++ b/src/gui/hotkey.rs @@ -449,54 +449,62 @@ mod tests { }) } - #[gpui_kit::test] - fn test_hotkey_clear_saves_and_remains_disabled_after_reload( - cx: &mut gpui_kit::TestAppContext, + fn open_hotkey_board( + cx: &gpui_kit::TestAppContext, + directory: &std::path::Path, + updates: async_channel::Sender, + ) -> ( + gpui_kit::AnyWindowHandle, + gpui_kit::Entity, ) { - use std::sync::{Arc, Mutex, RwLock}; - - use gpui_kit::{AppContext, test::TestWindowExt}; - use crate::{ clipboard::CopyTracker, config::Settings, gui::{board::RopyBoard, repository::GlobalRepository}, }; + use gpui_kit::AppContext; + use std::sync::{Arc, Mutex, RwLock}; - let directory = tempfile::tempdir().expect("settings directory"); - let settings = Settings::default(); - let initial_hotkey = settings.hotkey.activation_key.clone(); - let (updates, requests) = async_channel::unbounded(); let (copy_tx, _copy_rx) = async_channel::unbounded(); cx.update(|cx| { gpui_kit::init(cx); cx.set_reduce_motion(true); cx.set_global(GlobalSettings::at_path( - settings, - Some(directory.path().join("config.toml")), + Settings::default(), + Some(directory.join("config.toml")), )); cx.set_global(GlobalRepository::new(None)); crate::gui::board::bind_application_keys(cx); - }); - let (handle, board) = cx - .update(|cx| { - gpui_kit::open_window(gpui_kit::WindowOptions::default(), cx, |window, cx| { - cx.new(|cx| { - let mut board = RopyBoard::new( - Arc::new(RwLock::new(Vec::new())), - Arc::new(Mutex::new(CopyTracker::default())), - copy_tx, - window, - cx, - ); - board.activated = true; - board.pinned = true; - board.set_hotkey_tx(updates); - board - }) + gpui_kit::open_window(gpui_kit::WindowOptions::default(), cx, |window, cx| { + cx.new(|cx| { + let mut board = RopyBoard::new( + Arc::new(RwLock::new(Vec::new())), + Arc::new(Mutex::new(CopyTracker::default())), + copy_tx, + window, + cx, + ); + board.activated = true; + board.pinned = true; + board.set_hotkey_tx(updates); + board }) }) - .expect("open board"); + .expect("open board") + }) + } + + #[gpui_kit::test] + fn test_hotkey_clear_saves_and_remains_disabled_after_reload( + cx: &mut gpui_kit::TestAppContext, + ) { + use crate::config::Settings; + use gpui_kit::{AppContext, test::TestWindowExt}; + + let directory = tempfile::tempdir().expect("settings directory"); + let initial_hotkey = Settings::default().hotkey.activation_key; + let (updates, requests) = async_channel::unbounded(); + let (handle, board) = open_hotkey_board(cx, directory.path(), updates); cx.update_window(handle, |_, window, cx| { board.update(cx, |board, cx| board.open_settings_panel(window, cx)); window.render_frame(cx); @@ -544,6 +552,157 @@ mod tests { assert_eq!(reloaded.hotkey.activation_key, ""); } + #[gpui_kit::test] + fn test_hotkey_save_completion_preserves_newer_draft_across_windows( + cx: &mut gpui_kit::TestAppContext, + ) { + use crate::config::Settings; + use gpui_kit::AppContext; + + for reopen in [false, true] { + let directory = tempfile::tempdir().expect("settings directory"); + let (updates, requests) = async_channel::unbounded(); + let (mut handle, board) = open_hotkey_board(cx, directory.path(), updates); + cx.update_window(handle, |_, window, cx| { + board.update(cx, |board, cx| { + board.open_settings_panel(window, cx); + board + .settings_editor + .settings_activation_key_input + .update(cx, |input, cx| { + input.set_value("ctrl+shift+v", window, cx); + }); + board.save_hotkey(cx, window); + }); + }) + .expect("submit shortcut"); + let request = requests.try_recv().expect("pending registration"); + if reopen { + cx.update_window(handle, |_, window, _| window.remove_window()) + .expect("close window"); + cx.run_until_parked(); + handle = cx.update(|cx| { + crate::gui::app::reopen_window(cx, board.clone()) + .expect("reopen board") + .into() + }); + } + cx.update_window(handle, |_, window, cx| { + board.update(cx, |board, cx| { + board.open_settings_panel(window, cx); + board + .settings_editor + .settings_activation_key_input + .update(cx, |input, cx| { + input.set_value("ctrl+alt+p", window, cx); + }); + board.save_hotkey(cx, window); + }); + }) + .expect("edit next shortcut"); + assert!( + requests.is_empty(), + "an in-flight save must block another submission after reopening" + ); + let mut state = HotkeyListenerState { + current_hotkey: Settings::default().hotkey.activation_key, + manager: Some(()), + }; + let async_app = cx.to_async(); + process_listener_message( + &mut state, + ListenerMessage::UpdateHotkey(request), + &mut |_| Some(()), + &mut |key| persist_hotkey(key, &async_app), + &mut |_| {}, + ); + cx.run_until_parked(); + cx.update(|cx| { + assert_eq!( + GlobalSettings::read(cx, |s| s.hotkey.activation_key.clone()), + "ctrl+shift+v" + ); + board.update(cx, |board, cx| { + assert_eq!( + board.resolve_activation_key_input(cx), + "ctrl+alt+p", + "old completion replaced a newer draft (reopen={reopen})" + ); + assert!(board.has_pending_hotkey(cx)); + assert!(!board.settings_editor.hotkey.saving); + }); + }); + assert_eq!( + Settings::load_from_dir(directory.path()) + .expect("reload settings") + .hotkey + .activation_key, + "ctrl+shift+v" + ); + cx.update_window(handle, |_, window, cx| { + board.update(cx, |board, cx| { + board + .settings_editor + .settings_activation_key_input + .update(cx, |input, cx| { + input.set_value("", window, cx); + }); + assert!( + !board.has_pending_hotkey(cx), + "clearing a typed draft should restore the saved shortcut" + ); + }); + window.remove_window(); + }) + .expect("clear draft and close fixture"); + cx.run_until_parked(); + } + } + + #[gpui_kit::test] + fn test_hotkey_completion_while_closed_releases_submission_guard( + cx: &mut gpui_kit::TestAppContext, + ) { + use gpui_kit::AppContext; + + let directory = tempfile::tempdir().expect("settings directory"); + let (updates, requests) = async_channel::unbounded(); + let (handle, board) = open_hotkey_board(cx, directory.path(), updates); + cx.update_window(handle, |_, window, cx| { + board.update(cx, |board, cx| { + board + .settings_editor + .settings_activation_key_input + .update(cx, |input, cx| { + input.set_value("ctrl+shift+v", window, cx); + }); + board.save_hotkey(cx, window); + }); + window.remove_window(); + }) + .expect("submit and close window"); + cx.run_until_parked(); + let request = requests.try_recv().expect("pending registration"); + let result = persist_hotkey(&request.hotkey, &cx.to_async()); + assert!(result.is_ok()); + request + .completion + .try_send(result) + .expect("complete registration"); + cx.run_until_parked(); + cx.update(|cx| assert!(!board.read(cx).settings_editor.hotkey.saving)); + let handle = cx + .update(|cx| crate::gui::app::reopen_window(cx, board.clone()).expect("reopen board")); + cx.update_window(handle.into(), |_, window, cx| { + board.update(cx, |board, cx| { + assert_eq!(board.resolve_activation_key_input(cx), "ctrl+shift+v"); + assert!(!board.has_pending_hotkey(cx)); + }); + window.remove_window(); + }) + .expect("saved shortcut after reopening"); + } + #[gpui_kit::test] fn test_pending_registration_dispatches_existing_hotkey_before_completion( cx: &gpui_kit::TestAppContext, diff --git a/src/main.rs b/src/main.rs index 8d00ee4..b490ba0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,7 +42,7 @@ fn main() -> Result<(), Box> { return Ok(()); } - if updater::transaction::handle_startup()? { + if utils::logging::with_stderr_logging(updater::transaction::handle_startup)? { return Ok(()); } diff --git a/src/utils/logging.rs b/src/utils/logging.rs index 0c1c4a8..bdd4a57 100644 --- a/src/utils/logging.rs +++ b/src/utils/logging.rs @@ -44,6 +44,11 @@ fn build_file_subscriber( .finish() } +/// Capture startup and update-helper diagnostics without accessing user log files. +pub(crate) fn with_stderr_logging(operation: impl FnOnce() -> R) -> R { + tracing::subscriber::with_default(build_file_subscriber(std::io::stderr), operation) +} + /// Initialize JSON logging. /// /// - Enabled by default diff --git a/tests/update_handoff.rs b/tests/update_handoff.rs index 281e117..e857b72 100644 --- a/tests/update_handoff.rs +++ b/tests/update_handoff.rs @@ -2,7 +2,7 @@ use std::{ fs, - path::{Path, PathBuf}, + path::Path, process::{Child, Command}, thread, time::{Duration, Instant}, @@ -31,39 +31,50 @@ fn wait_until(stage: &str, mut condition: impl FnMut() -> bool) { } #[expect(clippy::expect_used)] -fn fixture(directory: &Path) -> PathBuf { - let binary = directory.join(if cfg!(windows) { - "fixture.exe" - } else { - "fixture" - }); +fn compile_fixture(binary: &Path, opt_level: u8) { let status = Command::new("rustc") .arg("--edition=2024") + .arg("-C") + .arg(format!("opt-level={opt_level}")) .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/update_process.rs")) .arg("-o") - .arg(&binary) + .arg(binary) .status() .expect("compile subprocess fixture"); assert!(status.success()); - binary +} + +#[derive(Clone, Copy)] +enum HandoffScenario { + Healthy, + FailedStartup, + StaleInstallation, } #[rstest::rstest] -#[case(false)] -#[case(true)] -fn test_restart_handoff_waits_for_parent_and_recovers_failed_startup(#[case] fail_startup: bool) { - run_handoff(fail_startup); +#[case(HandoffScenario::Healthy)] +#[case(HandoffScenario::FailedStartup)] +#[case(HandoffScenario::StaleInstallation)] +fn test_restart_handoff_waits_for_parent_and_recovers_failed_startup( + #[case] scenario: HandoffScenario, +) { + run_handoff(scenario); } #[expect(clippy::expect_used)] -fn run_handoff(fail_startup: bool) { +fn run_handoff(scenario: HandoffScenario) { let temporary = tempfile::tempdir().expect("temporary installation"); let directory = temporary .path() .canonicalize() .expect("absolute installation path"); let directory = directory.as_path(); - let binary = fixture(directory); + let binary = directory.join(if cfg!(windows) { + "fixture.exe" + } else { + "fixture" + }); + compile_fixture(&binary, 0); let target = directory.join(if cfg!(windows) { "ropy.exe" } else { "ropy" }); let root = target.with_file_name(format!( ".{}-update", @@ -91,7 +102,7 @@ fn run_handoff(fail_startup: bool) { .expect("serialize transaction"), ) .expect("persist transaction"); - if fail_startup { + if matches!(scenario, HandoffScenario::FailedStartup) { fs::write(root.join("fail-startup"), "fail").expect("failure scenario"); } let process_log = fs::File::create(root.join("process.log")).expect("process log"); @@ -117,12 +128,19 @@ fn run_handoff(fail_startup: bool) { assert!(!root.join("new-started").exists()); assert!(!root.join("backup").exists()); assert!(root.join("staged").exists()); + if matches!(scenario, HandoffScenario::StaleInstallation) { + // A different optimization level supplies a different fingerprint while + // retaining a valid native executable, including its macOS code signature. + compile_fixture(&target, 1); + } fs::write(root.join("exit-parent"), "exit").expect("request parent exit"); assert!(parent.0.wait().expect("wait for parent exit").success()); - if fail_startup { - wait_until("rollback startup", || root.join("old-started").exists()); - assert!(root.join("rolled-back").exists()); - } else { + assert_completed_handoff(&root, &target, scenario); +} + +#[expect(clippy::expect_used)] +fn assert_completed_handoff(root: &Path, target: &Path, scenario: HandoffScenario) { + if matches!(scenario, HandoffScenario::Healthy) { wait_until("healthy startup and backup cleanup", || { assert!( !root.join("rolled-back").exists(), @@ -133,12 +151,26 @@ fn run_handoff(fail_startup: bool) { root.join("healthy").exists() && !root.join("backup").exists() }); assert!(!root.join("old-started").exists()); + } else { + wait_until("rollback startup", || root.join("old-started").exists()); + assert!(root.join("rolled-back").exists()); + if matches!(scenario, HandoffScenario::StaleInstallation) { + let diagnostics = fs::read_to_string(root.join("process.log")).expect("helper stderr"); + assert!( + diagnostics.contains("could not install staged update"), + "missing rollback diagnostic: {diagnostics}" + ); + assert!(diagnostics.contains("staged update no longer matches")); + } } - assert!(root.join("new-started").exists()); + assert_eq!( + root.join("new-started").exists(), + !matches!(scenario, HandoffScenario::StaleInstallation) + ); assert!(!root.join("staged").exists()); assert!(target.is_file()); // On Windows an executing image cannot be removed until its process exits. wait_until("updated executable release", || { - fs::remove_file(&target).is_ok() + fs::remove_file(target).is_ok() }); }