From cbcda527ab071e6af6f35baafd7c608cc16cc9c9 Mon Sep 17 00:00:00 2001 From: Eval Exec Date: Sun, 4 Oct 2026 18:16:45 +0800 Subject: [PATCH 1/2] fix(redisplay): rebuild forced window bodies after mutable image updates A display image spec can change in place without moving buffer modification ticks. image-flush on the newly mutated spec can also leave the old source's catalog generation unchanged. force-window-update previously moved only the generic redisplay generation, which retained body rows did not consult, so incremental layout reused stale geometry after an explicit forced update. Model all-window, window and buffer requests with ForcedBodyRedisplay and carry an opaque BodyRedisplayRevision through the retained layout key, snapshot freshness and speculative layout freshness. Keep presentation-only redisplay separate so mode-line/menu updates preserve reusable body rows. Follow GNU Emacs Fforce_window_update and REDISPLAY_BUFFER_WINDOWS: refresh mode lines on successful requests; accept displayed buffers and names; search exact buffer identities on visible frames, excluding even active minibuffers; return nil for invalid, dead, hidden or undisplayed targets. Reset the new runtime revisions on context construction and pdump reconstruction. Preserve explicit forcing of a live minibuffer window, with a regression that failed before correcting the buffer-target walk. The mutable-image geometry regression failed before the fix. Add scope, mode-line and indirect/hidden-buffer regressions, a live GNU return-value oracle and a paired terminal display-spec mutation test. Verified 117 layout and 15 core tests, two live GNU oracle tests and two terminal tests after rebasing onto main, including its new terminal-output regression. The oracle and forced-repaint terminal checks passed three repetitions without retries using the freshly rebuilt development binary. This is a separately reproduced editor bug discovered while investigating missing Telega avatars; it does not establish the cause of that original personal-session report. --- .../src/incremental_layout.rs | 16 + .../tests/scroll_classifier_test.rs | 1 + .../src/tests/engine_layout_validity_test.rs | 115 +++++++ .../tests/force_window_update.rs | 58 ++++ crates/neomacs-tui-tests/tests/tui.rs | 2 + .../emacs_core/display/dispnew/tests/mod.rs | 316 ++++++++++++++++++ .../src/emacs_core/display/window_cmds/mod.rs | 87 +++-- .../emacs_core/runtime/eval/command_loop.rs | 54 +++ .../src/emacs_core/runtime/eval/construct.rs | 6 + .../src/emacs_core/runtime/eval/mod.rs | 15 +- .../runtime/eval/pdump_reconstruct.rs | 3 + crates/neovm-core/src/window/display.rs | 2 + crates/neovm-core/src/window/mod.rs | 76 +++++ .../src/divergence/combos/complex/case_409.rs | 29 ++ 14 files changed, 756 insertions(+), 24 deletions(-) create mode 100644 crates/neomacs-tui-tests/tests/force_window_update.rs diff --git a/crates/neomacs-layout-engine/src/incremental_layout.rs b/crates/neomacs-layout-engine/src/incremental_layout.rs index fca548dbda..410520c6a7 100644 --- a/crates/neomacs-layout-engine/src/incremental_layout.rs +++ b/crates/neomacs-layout-engine/src/incremental_layout.rs @@ -170,6 +170,18 @@ pub struct RetainedWindowKey { /// window kept reusing the matrix that captured the image's 1x1 `Pending` /// placeholder and every async-decoded buffer image stayed one pixel. pub media_generation: u64, + /// Explicit body-redisplay revision for this window and its buffer + /// (`force-window-update`, see `neovm_core::window::BodyRedisplayRevision`). + /// + /// A Lisp caller can mutate an image/display spec in place and then ask for + /// a forced redisplay; no buffer tick, face counter, or media generation + /// moves (GNU's `image-flush` of a freshly mutated spec need not invalidate + /// the catalog entry for the *old* source), so without this term the + /// retained key matched and the stale body rows were reused. Kept separate + /// from `media_generation` (async decode) and from the generic + /// `redisplay_generation` (which also moves for chrome/menu-only work and + /// must not relayout text). + pub body_redisplay: neovm_core::window::BodyRedisplayRevision, pub buffer_id: u64, pub window_start: i64, pub point: i64, @@ -294,6 +306,10 @@ impl RetainedWindowKey { Self { fontset_generation: neovm_core::emacs_core::fontset::fontset_generation(), media_generation: evaluator.media_generation(), + body_redisplay: evaluator.body_redisplay_revision( + neovm_core::window::WindowId(p.window_id as u64), + neovm_core::buffer::BufferId(p.buffer_id), + ), char_table_revision: neovm_core::window::CharTableLayoutRevision::current(), symbol_property_revision: neovm_core::emacs_core::symbol::SymbolPropertyRevision::current(), diff --git a/crates/neomacs-layout-engine/src/incremental_layout/tests/scroll_classifier_test.rs b/crates/neomacs-layout-engine/src/incremental_layout/tests/scroll_classifier_test.rs index 087351775a..f512b0dadc 100644 --- a/crates/neomacs-layout-engine/src/incremental_layout/tests/scroll_classifier_test.rs +++ b/crates/neomacs-layout-engine/src/incremental_layout/tests/scroll_classifier_test.rs @@ -11,6 +11,7 @@ fn synthetic_key(window_start: i64, point: i64) -> RetainedWindowKey { symbol_property_revision: Default::default(), display_table: Default::default(), media_generation: 0, + body_redisplay: Default::default(), buffer_id: 1, window_start, point, diff --git a/crates/neomacs-layout-engine/src/tests/engine_layout_validity_test.rs b/crates/neomacs-layout-engine/src/tests/engine_layout_validity_test.rs index f4670dbefd..0ff6a194d7 100644 --- a/crates/neomacs-layout-engine/src/tests/engine_layout_validity_test.rs +++ b/crates/neomacs-layout-engine/src/tests/engine_layout_validity_test.rs @@ -545,6 +545,121 @@ fn retained_geometry_rejects_mutation_late_in_long_image_spec() { ); } +#[test] +fn forced_window_update_rebuilds_a_mutated_buffer_image_spec() { + assert_layout_mutation_with_host( + r#"(setq mutable-avatar (list 'image :type 'png :file "avatar.png" :width 20 :height 24)) + (put-text-property 1 2 'display mutable-avatar)"#, + r#"(setcdr mutable-avatar (list :type 'png :file "delivered-avatar.png" :width 60 :height 24)) + (image-flush mutable-avatar t) + (force-window-update)"#, + true, + Some(Box::new(ExplicitExtentImageHost)), + ); +} + +/// A presentation-only redisplay invalidation (`redisplay_generation` moves: +/// messages, chrome, mode lines) must NOT relayout body text. If retained +/// bodies were keyed on the generic generation this window would rebuild. +#[test] +fn presentation_only_redisplay_invalidation_retains_body_rows() { + let mut eval = Context::new(); + let buffer = eval.buffer_manager().current_buffer().unwrap().id(); + eval.buffer_manager_mut() + .get_mut(buffer) + .unwrap() + .insert(&"abcdefghij".repeat(20)); + let frame = eval + .frame_manager_mut() + .create_frame("presentation-only", 160, 160, buffer); + let window = eval.frame_manager().get(frame).unwrap().selected_window; + let mut engine = LayoutEngine::new_without_font_metrics(); + engine.layout_frame_rust(&mut eval, frame); + let original = eval + .fresh_window_display_snapshot(frame, window, buffer) + .unwrap() + .iter_points() + .collect::>(); + engine.layout_frame_rust(&mut eval, frame); + assert!( + engine.last_layout_stats().reused_rows > 0, + "unchanged inputs retain row reuse" + ); + + let generation_before = eval.redisplay_generation(); + // A real public presentation-only entry point: GNU's global + // `force-mode-line-update` raises update_mode_lines and rebuilds chrome, + // moving the generic redisplay generation without touching body inputs. + eval.eval_str("(force-mode-line-update t)") + .expect("force-mode-line-update"); + assert!( + eval.redisplay_generation() > generation_before, + "the generic redisplay generation moves" + ); + engine.layout_frame_rust(&mut eval, frame); + assert!( + engine.last_layout_stats().reused_rows > 0, + "a presentation-only invalidation must not relayout body text" + ); + let after = eval + .fresh_window_display_snapshot(frame, window, buffer) + .unwrap() + .iter_points() + .collect::>(); + assert_eq!( + original, after, + "presentation-only invalidation keeps geometry" + ); +} + +/// A forced body redisplay names exactly one window: the other frame's window +/// keeps reusing its retained rows, and only the targeted window escalates to +/// a full rebuild (GNU `Fforce_window_update`, src/window.c:4492). +#[test] +fn forced_body_redisplay_rebuilds_only_the_targeted_window() { + use neovm_core::window::ForcedBodyRedisplay; + + let mut eval = Context::new(); + let buffer_a = eval.buffer_manager().current_buffer().unwrap().id(); + eval.buffer_manager_mut() + .get_mut(buffer_a) + .unwrap() + .insert(&"a-line\n".repeat(40)); + let frame_a = eval + .frame_manager_mut() + .create_frame("forced-a", 160, 160, buffer_a); + let buffer_b = eval.buffer_manager_mut().create_buffer("forced-b"); + eval.buffer_manager_mut() + .get_mut(buffer_b) + .unwrap() + .insert(&"b-line\n".repeat(40)); + let frame_b = eval + .frame_manager_mut() + .create_frame("forced-b-frame", 160, 160, buffer_b); + let window_a = eval.frame_manager().get(frame_a).unwrap().selected_window; + + let mut engine = LayoutEngine::new_without_font_metrics(); + engine.layout_frame_rust(&mut eval, frame_a); + engine.layout_frame_rust(&mut eval, frame_b); + engine.layout_frame_rust(&mut eval, frame_b); + assert!( + engine.last_layout_stats().reused_rows > 0, + "the unchanged second frame reuses its body rows" + ); + + eval.force_body_redisplay(ForcedBodyRedisplay::Window(window_a)); + engine.layout_frame_rust(&mut eval, frame_b); + assert!( + engine.last_layout_stats().reused_rows > 0, + "forcing another window's body must not relayout this window" + ); + engine.layout_frame_rust(&mut eval, frame_a); + assert!( + engine.last_layout_stats().full_windows > 0, + "the targeted window must rebuild its body" + ); +} + #[test] fn retained_presentation_rejects_mutated_prefix_stipple_bytes() { assert_decoration_mutation_invalidates_presentation( diff --git a/crates/neomacs-tui-tests/tests/force_window_update.rs b/crates/neomacs-tui-tests/tests/force_window_update.rs new file mode 100644 index 0000000000..6822c9640d --- /dev/null +++ b/crates/neomacs-tui-tests/tests/force_window_update.rs @@ -0,0 +1,58 @@ +#![cfg(unix)] +//! Public terminal coverage of the body-invalidation contract. TTY frames +//! cannot show avatars, so use an in-place mutation of a buffer display spec. + +use crate::support::*; +use std::time::Duration; + +#[test] +fn force_window_update_repaints_mutated_display_spec_like_gnu() { + let (mut gnu, mut neo) = boot_pair(""); + eval_expression( + &mut gnu, + &mut neo, + r#"(progn + (switch-to-buffer (get-buffer-create "*forced-body*")) + (erase-buffer) + (insert "xFORCED-BODY\n") + (setq neo-forced-body-spec (list 'space :width 2)) + (put-text-property 1 2 'display neo-forced-body-spec) + (goto-char (point-max)) + nil)"#, + ); + wait_for_both(&mut gnu, &mut neo, Duration::from_secs(12), |grid| { + grid.iter().any(|row| row.starts_with(" FORCED-BODY")) + }); + for (name, session) in [("GNU", &gnu), ("Neomacs", &neo)] { + assert!( + session + .text_grid() + .iter() + .any(|row| row.starts_with(" FORCED-BODY")), + "{name} must render the initial two-cell display spec:\n{}", + session.text_grid().join("\n") + ); + } + + // No text/property assignment: the same cons changes behind the buffer + // ticks. The explicit force must repaint without a further keypress. + eval_expression( + &mut gnu, + &mut neo, + "(progn (setcar (nthcdr 2 neo-forced-body-spec) 6) (force-window-update (current-buffer)) nil)", + ); + wait_for_both(&mut gnu, &mut neo, Duration::from_secs(12), |grid| { + grid.iter().any(|row| row.starts_with(" FORCED-BODY")) + }); + for (name, session) in [("GNU", &gnu), ("Neomacs", &neo)] { + assert!( + session + .text_grid() + .iter() + .any(|row| row.starts_with(" FORCED-BODY")), + "{name} must render the forced six-cell display spec:\n{}", + session.text_grid().join("\n") + ); + } + assert_pair_exact_display("forced mutable display spec", &gnu, &neo); +} diff --git a/crates/neomacs-tui-tests/tests/tui.rs b/crates/neomacs-tui-tests/tests/tui.rs index fabe0447d9..32b27d77c4 100644 --- a/crates/neomacs-tui-tests/tests/tui.rs +++ b/crates/neomacs-tui-tests/tests/tui.rs @@ -37,6 +37,8 @@ mod face_color_test; mod face_parity; #[path = "files_dired.rs"] mod files_dired; +#[path = "force_window_update.rs"] +mod force_window_update; #[path = "frame_visibility.rs"] mod frame_visibility; #[path = "help_describe.rs"] diff --git a/crates/neovm-core/src/emacs_core/display/dispnew/tests/mod.rs b/crates/neovm-core/src/emacs_core/display/dispnew/tests/mod.rs index 15266edfb7..9581a0f783 100644 --- a/crates/neovm-core/src/emacs_core/display/dispnew/tests/mod.rs +++ b/crates/neovm-core/src/emacs_core/display/dispnew/tests/mod.rs @@ -313,6 +313,322 @@ fn force_window_update_live_window_returns_t() { assert_eq!(result, Value::T); } +/// Two displayed windows showing different buffers: the initial frame's +/// selected window (`window_a`/`buffer_a`) and a second frame's selected +/// window (`window_b`/`buffer_b`), for body-redisplay scope assertions. +struct ForceWindowUpdateFixture { + window_a: Value, + window_a_id: crate::window::WindowId, + buffer_a: crate::buffer::BufferId, + window_b: Value, + window_b_id: crate::window::WindowId, + buffer_b: crate::buffer::BufferId, +} + +fn force_window_update_fixture(eval: &mut crate::emacs_core::Context) -> ForceWindowUpdateFixture { + let window_a = crate::emacs_core::window_cmds::builtin_selected_window(eval, vec![]).unwrap(); + let window_a_id = crate::window::WindowId(window_a.as_window_id().expect("window value")); + let buffer_a = eval + .frames + .window_buffer_id(window_a_id) + .expect("selected window displays a buffer"); + let buffer_b = eval.buffer_manager_mut().create_buffer("neo-fwu-other"); + let frame_b = eval + .frame_manager_mut() + .create_frame("neo-fwu-frame2", 80, 25, buffer_b); + let window_b_id = eval + .frame_manager() + .get(frame_b) + .expect("created frame") + .selected_window; + ForceWindowUpdateFixture { + window_a, + window_a_id, + buffer_a, + window_b: Value::make_window(window_b_id.0), + window_b_id, + buffer_b, + } +} + +#[test] +fn force_window_update_all_marks_every_window_body() { + crate::test_utils::init_test_tracing(); + let mut eval = crate::emacs_core::Context::new(); + let fx = force_window_update_fixture(&mut eval); + let before_a = eval.body_redisplay_revision(fx.window_a_id, fx.buffer_a); + let before_b = eval.body_redisplay_revision(fx.window_b_id, fx.buffer_b); + let result = + crate::emacs_core::window_cmds::builtin_force_window_update(&mut eval, vec![Value::NIL]) + .unwrap(); + assert_eq!(result, Value::T); + assert_ne!( + eval.body_redisplay_revision(fx.window_a_id, fx.buffer_a), + before_a, + "nil OBJECT must mark the first window's body" + ); + assert_ne!( + eval.body_redisplay_revision(fx.window_b_id, fx.buffer_b), + before_b, + "nil OBJECT must mark the second window's body" + ); +} + +#[test] +fn force_window_update_live_window_marks_only_that_window_and_buffer() { + crate::test_utils::init_test_tracing(); + let mut eval = crate::emacs_core::Context::new(); + let fx = force_window_update_fixture(&mut eval); + let before_a = eval.body_redisplay_revision(fx.window_a_id, fx.buffer_a); + let before_unrelated = eval.body_redisplay_revision(fx.window_b_id, fx.buffer_b); + let result = + crate::emacs_core::window_cmds::builtin_force_window_update(&mut eval, vec![fx.window_a]) + .unwrap(); + assert_eq!(result, Value::T); + assert_ne!( + eval.body_redisplay_revision(fx.window_a_id, fx.buffer_a), + before_a, + "the targeted window's body must be marked" + ); + assert_eq!( + eval.body_redisplay_revision(fx.window_b_id, fx.buffer_b), + before_unrelated, + "an unrelated window showing another buffer must stay reusable" + ); +} + +#[test] +fn force_window_update_displayed_buffer_marks_all_its_windows() { + crate::test_utils::init_test_tracing(); + let mut eval = crate::emacs_core::Context::new(); + let fx = force_window_update_fixture(&mut eval); + // Show the SAME buffer in the second frame as well. + let buffer_a_value = + crate::emacs_core::window_cmds::builtin_window_buffer(&mut eval, vec![fx.window_a]) + .unwrap(); + crate::emacs_core::window_cmds::builtin_set_window_buffer( + &mut eval, + vec![fx.window_b, buffer_a_value], + ) + .unwrap(); + let before_a = eval.body_redisplay_revision(fx.window_a_id, fx.buffer_a); + let before_b = eval.body_redisplay_revision(fx.window_b_id, fx.buffer_a); + let result = crate::emacs_core::window_cmds::builtin_force_window_update( + &mut eval, + vec![buffer_a_value], + ) + .unwrap(); + assert_eq!(result, Value::T); + assert_ne!( + eval.body_redisplay_revision(fx.window_a_id, fx.buffer_a), + before_a, + "every window displaying the buffer is marked" + ); + assert_ne!( + eval.body_redisplay_revision(fx.window_b_id, fx.buffer_a), + before_b, + "a window in another frame displaying the same buffer is marked" + ); +} + +#[test] +fn force_window_update_buffer_name_reports_shown_and_unknown_names_stay_nil() { + crate::test_utils::init_test_tracing(); + let mut eval = crate::emacs_core::Context::new(); + let shown = crate::emacs_core::buffer::builtin_get_buffer_create( + &mut eval, + vec![Value::string("neo-fwu-shown")], + ) + .unwrap(); + let w1_value = + crate::emacs_core::window_cmds::builtin_selected_window(&mut eval, vec![]).unwrap(); + crate::emacs_core::window_cmds::builtin_set_window_buffer(&mut eval, vec![w1_value, shown]) + .unwrap(); + let result = crate::emacs_core::window_cmds::builtin_force_window_update( + &mut eval, + vec![Value::string("neo-fwu-shown")], + ) + .unwrap(); + assert_eq!( + result, + Value::T, + "a buffer name naming a displayed buffer returns t" + ); + let result = crate::emacs_core::window_cmds::builtin_force_window_update( + &mut eval, + vec![Value::string("neo-fwu-absent")], + ) + .unwrap(); + assert!( + result.is_nil(), + "a name with no buffer returns nil without signaling" + ); +} + +#[test] +fn force_window_update_undisplayed_buffer_or_dead_window_marks_nothing() { + crate::test_utils::init_test_tracing(); + let mut eval = crate::emacs_core::Context::new(); + let fx = force_window_update_fixture(&mut eval); + let hidden = crate::emacs_core::buffer::builtin_get_buffer_create( + &mut eval, + vec![Value::string("neo-fwu-hidden")], + ) + .unwrap(); + let before = eval.body_redisplay_revision(fx.window_a_id, fx.buffer_a); + let result = + crate::emacs_core::window_cmds::builtin_force_window_update(&mut eval, vec![hidden]) + .unwrap(); + assert!( + result.is_nil(), + "an undisplayed buffer returns nil (GNU BUFFER_LIVE_P && buffer_window_count)" + ); + let result = crate::emacs_core::window_cmds::builtin_force_window_update( + &mut eval, + vec![Value::make_window(9_999_999)], + ) + .unwrap(); + assert!(result.is_nil(), "a dead window returns nil"); + assert_eq!( + eval.body_redisplay_revision(fx.window_a_id, fx.buffer_a), + before, + "neither request may mark a body" + ); +} + +#[test] +fn presentation_only_invalidation_does_not_mark_body_redisplay() { + // The parent contract: chrome/menu/mode-line invalidation moves + // `redisplay_generation` but must NOT force body text to relayout. + crate::test_utils::init_test_tracing(); + let mut eval = crate::emacs_core::Context::new(); + let fx = force_window_update_fixture(&mut eval); + let before_a = eval.body_redisplay_revision(fx.window_a_id, fx.buffer_a); + let before_b = eval.body_redisplay_revision(fx.window_b_id, fx.buffer_b); + let generation_before = eval.redisplay_generation(); + eval.mark_chrome_dirty_all(); + eval.invalidate_redisplay(); + assert!( + eval.redisplay_generation() > generation_before, + "generic redisplay invalidation still moves" + ); + assert_eq!( + eval.body_redisplay_revision(fx.window_a_id, fx.buffer_a), + before_a, + "chrome/menu invalidation must not relayout body text" + ); + assert_eq!( + eval.body_redisplay_revision(fx.window_b_id, fx.buffer_b), + before_b + ); +} + +#[test] +fn force_window_update_buffer_on_hidden_frame_marks_nothing() { + for visibility in [ + crate::window::FrameVisibility::Invisible, + crate::window::FrameVisibility::Iconified, + ] { + let mut eval = crate::emacs_core::Context::new(); + let fx = force_window_update_fixture(&mut eval); + let frame = eval.frames.find_window_frame_id(fx.window_b_id).unwrap(); + eval.frames.get_mut(frame).unwrap().visibility = visibility; + let before = eval.body_redisplay_revision(fx.window_b_id, fx.buffer_b); + let result = crate::emacs_core::window_cmds::builtin_force_window_update( + &mut eval, + vec![Value::make_buffer(fx.buffer_b)], + ) + .unwrap(); + assert!( + result.is_nil(), + "GNU searches visible frames for a buffer force" + ); + assert_eq!( + eval.body_redisplay_revision(fx.window_b_id, fx.buffer_b), + before + ); + } +} + +#[test] +fn force_window_update_buffer_shown_only_in_active_minibuffer_marks_nothing() { + let mut eval = crate::emacs_core::Context::new(); + let frame_id = crate::emacs_core::window_cmds::ensure_selected_frame_id(&mut eval); + let frame = eval.frames.get(frame_id).unwrap(); + let window = frame.minibuffer_window.expect("minibuffer window"); + let buffer = frame.find_window(window).unwrap().buffer_id().unwrap(); + eval.minibuffers + .read_from_minibuffer(buffer, "M-x ", None, None) + .expect("activate the minibuffer"); + let before = eval.body_redisplay_revision(window, buffer); + let result = crate::emacs_core::window_cmds::builtin_force_window_update( + &mut eval, + vec![Value::make_buffer(buffer)], + ) + .unwrap(); + assert!( + result.is_nil(), + "GNU's buffer walk excludes even active minibuffers" + ); + assert_eq!(eval.body_redisplay_revision(window, buffer), before); + + // The explicit live-window branch has no minibuffer exclusion. + let result = crate::emacs_core::window_cmds::builtin_force_window_update( + &mut eval, + vec![Value::make_window(window.0)], + ) + .unwrap(); + assert!(result.is_truthy()); + assert_ne!(eval.body_redisplay_revision(window, buffer), before); +} + +#[test] +fn force_window_update_undisplayed_indirect_buffer_marks_nothing() { + let mut eval = crate::emacs_core::Context::new(); + let fx = force_window_update_fixture(&mut eval); + let indirect = eval + .eval_str("(make-indirect-buffer (window-buffer (selected-window)) \"neo-fwu-indirect\")") + .unwrap(); + let before = eval.body_redisplay_revision(fx.window_a_id, fx.buffer_a); + let result = + crate::emacs_core::window_cmds::builtin_force_window_update(&mut eval, vec![indirect]) + .unwrap(); + assert!( + result.is_nil(), + "showing the base buffer does not show this indirect buffer" + ); + assert_eq!( + eval.body_redisplay_revision(fx.window_a_id, fx.buffer_a), + before + ); +} + +#[test] +fn force_window_update_requests_mode_lines_for_each_successful_scope() { + // GNU raises update_mode_lines for nil, a live window, and each + // REDISPLAY_BUFFER_WINDOWS match. A force must refresh chrome as well + // as body rows, while keeping the body scope targeted. + for scope in 0..3 { + let mut eval = crate::emacs_core::Context::new(); + let fx = force_window_update_fixture(&mut eval); + eval.note_chrome_generated(fx.window_a_id); + eval.note_chrome_generated(fx.window_b_id); + let before = eval.menu_bar_rebuild_generation(); + let target = match scope { + 0 => Value::NIL, + 1 => fx.window_a, + _ => { + crate::emacs_core::window_cmds::builtin_window_buffer(&mut eval, vec![fx.window_a]) + .unwrap() + } + }; + crate::emacs_core::window_cmds::builtin_force_window_update(&mut eval, vec![target]) + .unwrap(); + assert_ne!(eval.menu_bar_rebuild_generation(), before); + assert!(eval.chrome_dirty().is_dirty(fx.window_a_id)); + } +} + #[test] fn eval_internal_show_cursor_per_window_state() { crate::test_utils::init_test_tracing(); diff --git a/crates/neovm-core/src/emacs_core/display/window_cmds/mod.rs b/crates/neovm-core/src/emacs_core/display/window_cmds/mod.rs index aec35b36e1..69614e8622 100644 --- a/crates/neovm-core/src/emacs_core/display/window_cmds/mod.rs +++ b/crates/neovm-core/src/emacs_core/display/window_cmds/mod.rs @@ -21,11 +21,11 @@ use crate::emacs_core::xdisp::motion::MotionEngine; use crate::window::WindowChromeLine; use crate::window::body::{WindowBodyAxis, WindowBodyCellSize, WindowBodyUnit}; use crate::window::{ - CombinationLimit, CursorTypeSymbol, DeleteResize, FrameDeletion, FrameDeletionSelectionPolicy, - FrameDivider, FrameFocusTracking, FrameFullscreen, FrameId, FrameManager, FrameParam, - FrameParamKey, FrameVisibility, Rect, SelectedFrameAfterDeletion, SplitDirection, - SplitPlacement, Window, WindowBufferDisplayDefaults, WindowFringeDefaults, WindowId, - WindowMargins, WindowScrollBarDefaults, is_valid_horizontal_scroll_bar_value, + CombinationLimit, CursorTypeSymbol, DeleteResize, ForcedBodyRedisplay, FrameDeletion, + FrameDeletionSelectionPolicy, FrameDivider, FrameFocusTracking, FrameFullscreen, FrameId, + FrameManager, FrameParam, FrameParamKey, FrameVisibility, Rect, SelectedFrameAfterDeletion, + SplitDirection, SplitPlacement, Window, WindowBufferDisplayDefaults, WindowFringeDefaults, + WindowId, WindowMargins, WindowScrollBarDefaults, is_valid_horizontal_scroll_bar_value, is_valid_vertical_scroll_bar_value, window_first_child_id, window_next_sibling_id, window_parent_id, window_prev_sibling_id, }; @@ -7782,15 +7782,22 @@ pub(crate) fn builtin_window_resize_apply_total( /// (force-window-update &optional OBJECT) -> t/nil /// -/// GNU `Fforce_window_update` (`src/window.c:4488`): +/// GNU `Fforce_window_update` (`src/window.c:4492`): /// -/// - nil OBJECT: mark everything for redisplay, return t. -/// - a live WINDOW: mark that window, return t. -/// - a buffer/string: return t iff that buffer is shown in some window. +/// - nil OBJECT: mark every window (`windows_or_buffers_changed`), return t. +/// - a live WINDOW: mark that window's body and its displayed buffer, return t. +/// - a buffer or buffer name: return t iff that live buffer is shown in some +/// window, marking every window displaying it. +/// - anything else (a dead window, an undisplayed or dead buffer, a string +/// naming no buffer, an arbitrary object): return nil without signaling. /// -/// Explicit force requests invalidate the redisplay signature even when the -/// visible window state is unchanged. A live window also yields t (oracle -/// test cx409), as GNU does. +/// Body invalidation travels through [`ForcedBodyRedisplay`] so it is explicit +/// and typed, and never through the generic redisplay generation: a +/// presentation-only redisplay request must not relayout body text. +/// +/// Each successful branch also raises GNU's global `update_mode_lines` +/// trigger through the existing chrome/menu boundary. This does not widen +/// the independently scoped body invalidation. pub(crate) fn builtin_force_window_update( eval: &mut crate::emacs_core::eval::Context, args: Vec, @@ -7798,22 +7805,56 @@ pub(crate) fn builtin_force_window_update( expect_max_args("force-window-update", &args, 1)?; let Some(object) = args.first().filter(|v| !v.is_nil()) else { // nil OBJECT: force all windows. - eval.invalidate_redisplay(); + eval.force_body_redisplay(ForcedBodyRedisplay::AllWindows); + eval.request_global_mode_line_update(); return Ok(Value::T); }; - // A live window forces just that window and returns t. - if let Some(id) = object.as_window_id() - && eval.frames.is_live_window_id(WindowId(id)) - { - eval.invalidate_redisplay(); - return Ok(Value::T); + // A live window forces just that window (and its displayed buffer). + if let Some(id) = object.as_window_id() { + let window = WindowId(id); + if eval.frames.is_live_window_id(window) { + eval.force_body_redisplay(ForcedBodyRedisplay::Window(window)); + eval.request_global_mode_line_update(); + return Ok(Value::T); + } + // A dead window vector is not an error in GNU; it just does nothing. + return Ok(Value::NIL); } - // A buffer (or buffer name) shown in at least one window also returns t in - // GNU; otherwise (dead window, unshown buffer, anything else) the value is - // nil -- the safe default neomacs already produced for those cases. - Ok(Value::NIL) + // A buffer vector or buffer name. Unlike buffer-designator resolution for + // editing primitives, an unknown name or a killed buffer must NOT signal. + let buffer = match object.kind() { + ValueKind::Veclike(VecLikeType::Buffer) => object + .as_buffer_id() + .filter(|id| eval.buffers.get(*id).is_some()), + ValueKind::String => find_buffer_by_name_arg(&eval.buffers, object)?, + _ => None, + }; + let Some(buffer) = buffer else { + return Ok(Value::NIL); + }; + // GNU's preliminary buffer_window_count includes indirect buffers and + // hidden frames. Its actual REDISPLAY_BUFFER_WINDOWS walk then requires + // exact contents on a visible frame. window_loop's mini=false excludes + // minibuffer windows even while active; explicit window forcing above + // still accepts a live minibuffer window. + let base_frame = ensure_selected_frame_id(eval); + let displayed = + frame_ids_for_all_frames_scope(&eval.frames, base_frame, AllFramesScope::VisibleFrames) + .into_iter() + .filter_map(|id| eval.frames.get(id)) + .any(|frame| { + frame.window_list().into_iter().any(|window| { + frame.find_window(window).and_then(Window::buffer_id) == Some(buffer) + }) + }); + if !displayed { + return Ok(Value::NIL); + } + eval.force_body_redisplay(ForcedBodyRedisplay::Buffer(buffer)); + eval.request_global_mode_line_update(); + Ok(Value::T) } // =========================================================================== diff --git a/crates/neovm-core/src/emacs_core/runtime/eval/command_loop.rs b/crates/neovm-core/src/emacs_core/runtime/eval/command_loop.rs index 5cad93b31b..ea86c133f1 100644 --- a/crates/neovm-core/src/emacs_core/runtime/eval/command_loop.rs +++ b/crates/neovm-core/src/emacs_core/runtime/eval/command_loop.rs @@ -1486,6 +1486,60 @@ impl Context { self.last_redisplay_signature = None; } + /// Record an explicit body redisplay request (GNU `Fforce_window_update`, + /// `src/window.c:4492`). + /// + /// Unlike [`Self::invalidate_redisplay`] this only moves the scopes the + /// request names, so a chrome/mode-line invalidation cannot relayout body + /// text and a targeted force cannot rebuild unrelated windows: + /// + /// - `AllWindows` — every window (nil OBJECT). + /// - `Window(W)` — live window W, and (mirroring GNU's + /// `prevent_redisplay_optimizations_p` on `w->contents`) the buffer it + /// displays, so every other window showing that buffer also rebuilds. + /// - `Buffer(B)` — every window displaying B. + pub fn force_body_redisplay(&mut self, target: ForcedBodyRedisplay) { + match target { + ForcedBodyRedisplay::AllWindows => { + self.body_redisplay_all = self.body_redisplay_all.wrapping_add(1); + } + ForcedBodyRedisplay::Window(window) => { + let counter = self.body_redisplay_by_window.entry(window).or_insert(0); + *counter = counter.wrapping_add(1); + if let Some(buffer) = self.frames.window_buffer_id(window) { + self.mark_buffer_body_redisplay(buffer); + } + } + ForcedBodyRedisplay::Buffer(buffer) => self.mark_buffer_body_redisplay(buffer), + } + self.invalidate_redisplay(); + } + + fn mark_buffer_body_redisplay(&mut self, buffer: BufferId) { + let counter = self.body_redisplay_by_buffer.entry(buffer).or_insert(0); + *counter = counter.wrapping_add(1); + } + + /// The body-redisplay revision that any retained layout of BUFFER inside + /// WINDOW must satisfy; see [`BodyRedisplayRevision`]. + pub fn body_redisplay_revision( + &self, + window: WindowId, + buffer: BufferId, + ) -> BodyRedisplayRevision { + BodyRedisplayRevision::new( + self.body_redisplay_all, + self.body_redisplay_by_window + .get(&window) + .copied() + .unwrap_or(0), + self.body_redisplay_by_buffer + .get(&buffer) + .copied() + .unwrap_or(0), + ) + } + /// Cross GNU `update_menu_bar`'s rebuild boundary and schedule redisplay. pub(crate) fn request_menu_bar_rebuild(&mut self, reason: MenuBarRebuildReason) { tracing::debug!(?reason, "request menu-bar rebuild"); diff --git a/crates/neovm-core/src/emacs_core/runtime/eval/construct.rs b/crates/neovm-core/src/emacs_core/runtime/eval/construct.rs index 4ffdbd01fa..7376ae77c6 100644 --- a/crates/neovm-core/src/emacs_core/runtime/eval/construct.rs +++ b/crates/neovm-core/src/emacs_core/runtime/eval/construct.rs @@ -98,6 +98,9 @@ impl Context { ev.face_change_count = 0; ev.display_var_change_count = 0; ev.redisplay_generation = 0; + ev.body_redisplay_all = 0; + ev.body_redisplay_by_window.clear(); + ev.body_redisplay_by_buffer.clear(); ev.menu_bar_rebuild_generation = 0; ev.media_generation = 0; ev.last_redisplay_signature = None; @@ -2268,6 +2271,9 @@ impl Context { display_var_change_count: 0, input_progress: Default::default(), redisplay_generation: 0, + body_redisplay_all: 0, + body_redisplay_by_window: FxHashMap::default(), + body_redisplay_by_buffer: FxHashMap::default(), menu_bar_rebuild_generation: 0, chrome_dirty: Default::default(), context_instance_id: next_context_instance_id(), diff --git a/crates/neovm-core/src/emacs_core/runtime/eval/mod.rs b/crates/neovm-core/src/emacs_core/runtime/eval/mod.rs index f19dd05e91..4dd56664c9 100644 --- a/crates/neovm-core/src/emacs_core/runtime/eval/mod.rs +++ b/crates/neovm-core/src/emacs_core/runtime/eval/mod.rs @@ -52,7 +52,10 @@ use crate::gc_trace::GcTrace; use crate::tagged::header::{ CLOSURE_ARGLIST, SubrDispatchKind, SubrFn, SubrInteractivity, SubrObj, }; -use crate::window::{FrameFullscreen, FrameManager, WindowId, WindowLayoutQueryAdapter}; +use crate::window::{ + BodyRedisplayRevision, ForcedBodyRedisplay, FrameFullscreen, FrameManager, WindowId, + WindowLayoutQueryAdapter, +}; mod callback; pub(crate) use callback::{CheckedNativeCallback, native_callback_cache_enabled}; @@ -3416,7 +3419,17 @@ pub struct Context { pub input_progress: neomacs_display_protocol::input_progress::InputProgress, /// Explicit redisplay invalidation generation, used for state that GNU /// marks with update_mode_lines/window redisplay flags. + /// + /// This counter also moves for presentation-only work (chrome, menu, + /// mode-line), so it must NOT be the reason a window relayouts its body + /// text; retained bodies key on [`BodyRedisplayRevision`] instead. redisplay_generation: u64, + /// Body-only redisplay revisions for `(force-window-update)`; see + /// [`BodyRedisplayRevision`]. Kept per window and per buffer so a targeted + /// force does not rebuild unrelated windows. + body_redisplay_all: u64, + body_redisplay_by_window: FxHashMap, + body_redisplay_by_buffer: FxHashMap, /// GNU `update_menu_bar` invalidation boundary. This is narrower than /// `redisplay_generation`; see [`MenuBarRebuildGeneration`]. menu_bar_rebuild_generation: u64, diff --git a/crates/neovm-core/src/emacs_core/runtime/eval/pdump_reconstruct.rs b/crates/neovm-core/src/emacs_core/runtime/eval/pdump_reconstruct.rs index 37836e0160..82d9a0fb7e 100644 --- a/crates/neovm-core/src/emacs_core/runtime/eval/pdump_reconstruct.rs +++ b/crates/neovm-core/src/emacs_core/runtime/eval/pdump_reconstruct.rs @@ -169,6 +169,9 @@ impl Context { display_var_change_count: 0, input_progress: Default::default(), redisplay_generation: 0, + body_redisplay_all: 0, + body_redisplay_by_window: FxHashMap::default(), + body_redisplay_by_buffer: FxHashMap::default(), menu_bar_rebuild_generation: 0, chrome_dirty: Default::default(), context_instance_id: next_context_instance_id(), diff --git a/crates/neovm-core/src/window/display.rs b/crates/neovm-core/src/window/display.rs index e18879dd39..4989d1f42f 100644 --- a/crates/neovm-core/src/window/display.rs +++ b/crates/neovm-core/src/window/display.rs @@ -564,6 +564,7 @@ impl crate::emacs_core::eval::Context { }, face_change_count: self.face_change_count, media_generation: self.media_generation(), + body_redisplay: self.body_redisplay_revision(window_id, live_buffer_id), function_epoch: self.obarray.function_epoch(), }) } @@ -860,6 +861,7 @@ impl crate::emacs_core::eval::Context { face_change_count: self.face_change_count, display_var_change_count: self.display_var_change_count, redisplay_generation: self.redisplay_generation(), + body_redisplay: self.body_redisplay_revision(window_id, buffer_id), media_generation: self.media_generation(), function_epoch: self.obarray().function_epoch(), symbol_property_revision: crate::emacs_core::symbol::SymbolPropertyRevision::current(), diff --git a/crates/neovm-core/src/window/mod.rs b/crates/neovm-core/src/window/mod.rs index 7dd0996546..ec6258eac8 100644 --- a/crates/neovm-core/src/window/mod.rs +++ b/crates/neovm-core/src/window/mod.rs @@ -2759,6 +2759,61 @@ pub struct WindowDisplaySnapshot { pub window_end_record: Option, } +/// Which window bodies an explicit `force-window-update` invalidated. +/// +/// Mirrors the three branches of GNU `Fforce_window_update` +/// (`src/window.c:4492`): +/// +/// - `AllWindows` — nil OBJECT: `windows_or_buffers_changed = 29`. +/// - `Window(W)` — a live window: W is marked inaccurate and W's buffer gets +/// `prevent_redisplay_optimizations_p`, so every window showing that buffer +/// also loses reuse. +/// - `Buffer(B)` — a displayed buffer (or its name): every window displaying B +/// is forced. +/// +/// The target is a closed sum type so a call site cannot smuggle an unrelated +/// boolean (or a naked counter) into a decision about *which* bodies must be +/// rebuilt. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ForcedBodyRedisplay { + AllWindows, + Window(WindowId), + Buffer(BufferId), +} + +/// Monotonic body-redisplay revisions consulted when a window may reuse +/// retained body rows. +/// +/// This is deliberately separate from `Context::redisplay_generation`: that +/// counter also moves for presentation-only work (mode-line/chrome/menu +/// updates) which must NOT relayout body text. Only an explicit body +/// invalidation — today GNU `force-window-update` — moves these counters, and +/// the three scopes below keep a targeted request from escalating unrelated +/// windows: +/// +/// - `all` — every window (nil OBJECT), +/// - `window` — one live window (and, through `buffer`, its displayed buffer), +/// - `buffer` — every window displaying that buffer. +/// +/// Equality is the reuse predicate: any move for this window/buffer pair makes +/// the retained key differ, which escalates to a full rebuild. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct BodyRedisplayRevision { + all: u64, + window: u64, + buffer: u64, +} + +impl BodyRedisplayRevision { + pub(crate) const fn new(all: u64, window: u64, buffer: u64) -> Self { + Self { + all, + window, + buffer, + } + } +} + /// Opaque identity of every mutable input used to lay out one live window. /// /// Construction and comparison live on `Context`; keeping these fields opaque @@ -2775,6 +2830,10 @@ pub struct WindowDisplaySnapshotFreshness { pub(crate) face_change_count: u64, pub(crate) display_var_change_count: u64, pub(crate) redisplay_generation: u64, + /// Body-only invalidation; see [`BodyRedisplayRevision`]. Unlike + /// `redisplay_generation` this is NOT aligned away by scroll-surface + /// compatibility, so a forced body redisplay also refuses scroll reuse. + pub(crate) body_redisplay: BodyRedisplayRevision, pub(crate) media_generation: u64, pub(crate) function_epoch: u64, pub(crate) symbol_property_revision: crate::emacs_core::symbol::SymbolPropertyRevision, @@ -2840,6 +2899,12 @@ pub struct WindowLayoutAttemptFreshness { selection: WindowLayoutSelectionState, face_change_count: u64, media_generation: u64, + /// Body-only invalidation for this window and its displayed buffer; see + /// [`BodyRedisplayRevision`]. A `force-window-update` issued while body + /// Lisp is running must not let that attempt publish rows produced from + /// the pre-invalidation inputs, and unlike `media_generation` it does not + /// depend on `image-flush` having actually changed a catalog entry. + body_redisplay: BodyRedisplayRevision, function_epoch: u64, } @@ -7221,6 +7286,17 @@ impl FrameManager { }) } + /// Buffer displayed by WINDOW_ID, if it is a live leaf window. + /// + /// Mirrors reading `XWINDOW (w)->contents`: GNU's `Fforce_window_update` + /// uses it to mark the displayed buffer (`prevent_redisplay_optimizations_p`) + /// in addition to the window itself. + pub fn window_buffer_id(&self, window_id: WindowId) -> Option { + self.frames + .values() + .find_map(|frame| frame.find_window(window_id).and_then(Window::buffer_id)) + } + /// Return true if WINDOW_ID is the minibuffer window of any live frame. /// (Structural replacement for the old `id >= MINIBUFFER_WINDOW_ID_BASE` /// magic-range check.) diff --git a/crates/neovm-oracle-tests/src/divergence/combos/complex/case_409.rs b/crates/neovm-oracle-tests/src/divergence/combos/complex/case_409.rs index 21843a2ca8..bfc3076501 100644 --- a/crates/neovm-oracle-tests/src/divergence/combos/complex/case_409.rs +++ b/crates/neovm-oracle-tests/src/divergence/combos/complex/case_409.rs @@ -333,6 +333,35 @@ fn div_cx409_force_window_update() { ); } +/// force-window-update: the GNU object contract (src/window.c:4492). +/// +/// nil marks every window, a live window marks that window, a displayed +/// buffer (or its name) is reported as shown, and an undisplayed buffer or +/// unknown name returns nil without signaling. +#[test] +fn div_cx409_force_window_update_object_contract() { + return_if_neovm_enable_oracle_proptest_not_set!(); + let expect = expect_test::expect![[r#""OK (t t t t nil nil nil)""#]]; + crate::common::assert_oracle_parity_expect( + r##" +(let* ((shown (window-buffer (selected-window))) + (hidden (generate-new-buffer "neo-fwu-hidden")) + (indirect (make-indirect-buffer shown "neo-fwu-indirect"))) + (unwind-protect + (list (force-window-update) + (force-window-update (selected-window)) + (force-window-update shown) + (force-window-update (buffer-name shown)) + (force-window-update hidden) + (force-window-update indirect) + (force-window-update "neo-fwu-no-such-buffer")) + (kill-buffer indirect) + (kill-buffer hidden))) +"##, + expect, + ); +} + /// translation-table / set-translation-table: /// character translation tables. #[test] From 61130a7445316d5f6207c02372da698065c0917b Mon Sep 17 00:00:00 2001 From: Eval Exec Date: Sun, 4 Oct 2026 18:17:04 +0800 Subject: [PATCH 2/2] test(telega): add account-free avatar fixtures on private displays Replace Telega's configurable telega-server subprocess with an offline Rust fixture while keeping the pinned Lisp frontend, process filter, callback dispatch, SVG construction, redisplay and paging real. Model framed UTF-8 plist requests and typed events with callback correlation; reject unsupported requests and unknown scenario identifiers instead of silently succeeding. Generate 112 synthetic group chats and PNG avatars, with controlled delayed file delivery and photo replacement. Provision Telega and its locked dependency closure through neomacs-infra rather than loading personal packages, Telegram data, credentials or captured conversations. Give each scenario a fresh HOME/XDG/temp/runtime tree, authenticated private Xvfb display and owned editor/mock process group. Strip user display, D-Bus and loading overrides. Verify avatar glyphs and screenshot pixels within each visible row, real PageDown/PageUp motion, delayed repaint, replacement and cleanup after both normal exit and panic. Cleanup signals only the recorded group, never processes by name. Verified the package pin, 23 fixture unit tests and all seven GUI tests. The unit and private GUI suites each passed three repetitions with retries disabled; final GUI checks used the freshly rebuilt development binary. Document provisioning, commands, artifacts and the configuration-isolation boundary: no OS network/filesystem sandbox or TDLib/MTProto coverage. The fixture also passes before the separate retained-layout fix, so these tests provide reproducible frontend coverage without claiming reproduction or resolution of the original missing-avatar report. --- .../fixtures/telega-fixture-gui.el | 276 +++ .../fixtures/telega-fixture.md | 194 ++ .../src/bin/neomacs-telega-fixture-mock.rs | 55 + crates/neomacs-gui-tests/src/lib.rs | 3 + .../src/telega_fixture/mock.rs | 1238 +++++++++++++ .../src/telega_fixture/mod.rs | 18 + .../src/telega_fixture/protocol.rs | 1166 ++++++++++++ .../src/telega_fixture/scenario.rs | 362 ++++ .../tests/telega_fixture_gui.rs | 1591 +++++++++++++++++ .../src/packages/melpa-package-lock.tsv | 1 + crates/neomacs-infra/src/packages/mod.rs | 2 +- .../neomacs-infra/src/packages/source_lock.rs | 5 + .../source_lock/tests/source_lock_test.rs | 39 + 13 files changed, 4949 insertions(+), 1 deletion(-) create mode 100644 crates/neomacs-gui-tests/fixtures/telega-fixture-gui.el create mode 100644 crates/neomacs-gui-tests/fixtures/telega-fixture.md create mode 100644 crates/neomacs-gui-tests/src/bin/neomacs-telega-fixture-mock.rs create mode 100644 crates/neomacs-gui-tests/src/telega_fixture/mock.rs create mode 100644 crates/neomacs-gui-tests/src/telega_fixture/mod.rs create mode 100644 crates/neomacs-gui-tests/src/telega_fixture/protocol.rs create mode 100644 crates/neomacs-gui-tests/src/telega_fixture/scenario.rs create mode 100644 crates/neomacs-gui-tests/tests/telega_fixture_gui.rs diff --git a/crates/neomacs-gui-tests/fixtures/telega-fixture-gui.el b/crates/neomacs-gui-tests/fixtures/telega-fixture-gui.el new file mode 100644 index 0000000000..99d75aac67 --- /dev/null +++ b/crates/neomacs-gui-tests/fixtures/telega-fixture-gui.el @@ -0,0 +1,276 @@ +;;; telega-fixture-gui.el --- account-free Telega GUI fixture scenario -*- lexical-binding: t; -*- +;; +;; This scenario runs inside `neomacs -Q -l' on a harness-owned private +;; display. It mounts the pinned Telega frontend provisioned by +;; neomacs-infra through an explicit `load-path' built by the harness (no +;; package discovery), points Telega's REAL `telega-server-command' at the +;; offline mock process (`NEOMACS_TELEGA_MOCK'), and drives the real root +;; buffer: chat list, avatars, paging. +;; +;; Isolation contract: every writable path is below +;; `NEOMACS_TELEGA_FIXTURE_ROOT' (which the harness creates fresh), HOME/XDG +;; point into that tree, and no personal configuration, Telegram account, +;; database, cache, photo, or network connection is used. This is +;; *configuration* isolation: the fixture never reads a user path through +;; Telega, but it does not create a filesystem/network sandbox, so the +;; assertions below check the paths the fixture set rather than claiming an +;; OS-level boundary. The harness additionally verifies the process +;; environment from /proc. +;; +;; The scenario writes `checkpoint.json' periodically (window-start/window-end, +;; chat count, auth state, the delivered avatar's file state) so the Rust test +;; can wait on readiness instead of sleeping, and answers `snapshot-request' +;; files with a full frame snapshot. On any error it writes +;; `scenario-error.json' and exits nonzero. + +(defconst tf/root (getenv "NEOMACS_TELEGA_FIXTURE_ROOT") + "Fixture-owned root directory.") +(defconst tf/mock (getenv "NEOMACS_TELEGA_MOCK") + "Offline telega-server stand-in the harness built.") +(defconst tf/source (getenv "NEOMACS_TELEGA_SOURCE") + "Pinned Telega source file prepared by neomacs-infra.") +(defconst tf/load-path (getenv "NEOMACS_TELEGA_LOAD_PATH") + "Colon-separated provisioned package directories (Telega + dependencies).") +(defconst tf/empty-elpa (getenv "NEOMACS_TELEGA_EMPTY_ELPA") + "Fixture-owned empty package directory; package.el never sees a user tree.") +(defconst tf/checkpoint-path (expand-file-name "checkpoint.json" tf/root)) +(defconst tf/request-path (expand-file-name "snapshot-request" tf/root)) +(defconst tf/ready-path (expand-file-name "snapshot-ready" tf/root)) +(defconst tf/quit-path (expand-file-name "quit-request" tf/root)) +(defconst tf/error-path (expand-file-name "scenario-error.json" tf/root)) +(defconst tf/isolation-path (expand-file-name "isolation.json" tf/root)) +(defconst tf/snapshot-path (getenv "NEOMACS_GUI_FRAME_SNAPSHOT_JSON")) + +(defvar tf/chats-loaded nil) +(defvar tf/generation 0) +(defvar tf/started-at (float-time)) + +(defun tf/write-atomic (path contents) + "Write CONTENTS to PATH via rename so readers never see a partial file." + (let ((tmp (format "%s.tmp.%d" path (emacs-pid)))) + (write-region contents nil tmp nil 'quiet) + (rename-file tmp path t))) + +(defun tf/fail (error) + "Record ERROR and exit nonzero; the harness treats this as a scenario failure." + (let ((payload (list :kind "scenario-error" + :error (format "%S" error) + :backtrace (condition-case nil + (with-output-to-string (backtrace)) + (error ""))))) + (ignore-errors (tf/write-atomic tf/error-path (json-encode payload))) + (kill-emacs 3))) + +(defmacro tf/guard (&rest body) + "Run BODY, turning any error into a recorded scenario failure." + `(condition-case err (progn ,@body) (error (tf/fail err)))) + +(defun tf/assert-isolation () + "Refuse to run outside the fixture-owned environment." + (unless (and tf/root (file-directory-p tf/root)) + (error "NEOMACS_TELEGA_FIXTURE_ROOT is missing or not a directory")) + (unless (and tf/mock (file-executable-p tf/mock)) + (error "NEOMACS_TELEGA_MOCK is missing or not executable")) + (unless (and tf/source (file-readable-p tf/source)) + (error "NEOMACS_TELEGA_SOURCE is missing or unreadable")) + (unless (and tf/load-path (not (string-empty-p tf/load-path))) + (error "NEOMACS_TELEGA_LOAD_PATH is empty")) + (unless (and tf/empty-elpa (file-directory-p tf/empty-elpa)) + (error "NEOMACS_TELEGA_EMPTY_ELPA is missing or not a directory")) + (when (getenv "EMACSLOADPATH") + (error "EMACSLOADPATH must be stripped; the fixture builds load-path explicitly")) + (let ((home (expand-file-name "~/")) + (display (getenv "DISPLAY")) + (wayland (getenv "WAYLAND_DISPLAY")) + (runtime (getenv "XDG_RUNTIME_DIR"))) + (unless (string-prefix-p (file-name-as-directory tf/root) home) + (error "HOME %s escapes the fixture root %s" home tf/root)) + (unless (and display (not (string-empty-p display))) + (error "no private DISPLAY was provided")) + (when (and wayland (not (string-empty-p wayland))) + (error "WAYLAND_DISPLAY must not route the X11 fixture at a user session")) + (when (getenv "WAYLAND_SOCKET") + (error "WAYLAND_SOCKET must be absent, not merely empty")) + (when (getenv "DBUS_SESSION_BUS_ADDRESS") + (error "DBUS_SESSION_BUS_ADDRESS must not point at the user's bus")) + ;; The harness publishes the owned runtime directory through + ;; /proc//fd/; the Rust side canonicalizes it against the owned + ;; directory, so here it is enough that it exists and is a directory. + (unless (and runtime (file-directory-p runtime)) + (error "XDG_RUNTIME_DIR %s is not a readable directory" runtime))) + (dolist (variable '("HOME" "XDG_CONFIG_HOME" "XDG_CACHE_HOME" "XDG_DATA_HOME" + "XDG_STATE_HOME")) + (let ((value (getenv variable))) + (when (and value + (not (string-prefix-p (file-name-as-directory tf/root) + (file-name-as-directory value)))) + (error "%s=%s escapes the fixture root" variable value))))) + +(defun tf/record-isolation () + "Persist the scenario's view of its isolation for the harness to verify." + (tf/write-atomic + tf/isolation-path + (concat + (json-encode + (list :root tf/root + :home (expand-file-name "~/") + :display (getenv "DISPLAY") + :runtime-dir (getenv "XDG_RUNTIME_DIR") + :runtime-truename (condition-case nil + (file-truename (or (getenv "XDG_RUNTIME_DIR") "/")) + (error "unresolved")) + :wayland-display (or (getenv "WAYLAND_DISPLAY") "unset") + :wayland-socket (or (getenv "WAYLAND_SOCKET") "unset") + :dbus (or (getenv "DBUS_SESSION_BUS_ADDRESS") "unset") + :emacsloadpath (or (getenv "EMACSLOADPATH") "unset") + :source tf/source + :load-path (split-string tf/load-path ":" t) + :telega-directory telega-directory + :telega-database-dir telega-database-dir + :telega-cache-dir telega-cache-dir + :server-command telega-server-command + :server-logfile (format "%S" telega-server-logfile) + :graphic (and (display-graphic-p) t))) + "\n"))) + +(require 'json) + +(defun tf/configure-telega-paths () + "Point every Telega path and command at the fixture, never at a user tree. +Run BEFORE loading Telega so no defcustom default is ever computed from the +ambient environment." + (setq telega-directory (expand-file-name "telega-dir" tf/root) + telega-database-dir (expand-file-name "telega-db" tf/root) + telega-cache-dir (expand-file-name "telega-cache" tf/root) + telega-temp-dir (expand-file-name "telega-temp" tf/root) + telega-server-command tf/mock + telega-server-logfile nil + telega-use-docker nil + telega-use-test-dc nil + telega-use-images t + telega-root-show-avatars t + telega-chat-show-avatars t + telega-use-file-database nil + telega-use-chat-info-database nil + telega-use-message-database nil + telega-debug nil)) + +(defun tf/mount-pinned-telega () + "Mount exactly the provisioned packages and load the pinned Telega source. +`package.el' is denied any discovery surface: an empty user directory and no +directory list; `load-path' is the harness-provided package list only." + (require 'package) + (setq package-user-dir tf/empty-elpa + package-directory-list nil + package-check-signature nil + package-enable-at-startup nil) + (dolist (directory (split-string tf/load-path ":" t)) + (unless (file-directory-p directory) + (error "provisioned load-path entry %s is not a directory" directory)) + (add-to-list 'load-path directory)) + (unless (file-readable-p tf/source) + (error "pinned Telega source %s is unreadable" tf/source)) + (load tf/source nil t t)) + +(defconst tf/probe-file-id 5000 + "File id of the first synthetic chat's avatar (see the Rust scenario).") + +(defun tf/avatar-state () + "Frontend state of the first synthetic chat's avatar, for checkpoints. +`:file-table-*' reflects Telega's own file table, which is where a real +`updateFile' lands; `:avatar-file-*' reflects the chat's renewed photo object +and `:avatar-spec-*' the cached avatar image Telega built for it." + (let* ((chat (and (boundp 'telega--chats) + (hash-table-p telega--chats) + (gethash 1000 telega--chats))) + (photo (and chat (plist-get chat :photo))) + (chat-file (and photo (plist-get photo :small))) + (chat-local (and chat-file (plist-get chat-file :local))) + (table-file (and (boundp 'telega--files) + (hash-table-p telega--files) + (gethash tf/probe-file-id telega--files))) + (table-local (and table-file (plist-get table-file :local))) + (image (and chat (plist-get chat :telega-avatar-1))) + (data (and image (plist-get (cdr image) :data)))) + (list :file-table-path (and table-local (plist-get table-local :path)) + :file-table-downloaded (and table-local + (plist-get table-local :is_downloading_completed) + t) + :avatar-file-path (and chat-local (plist-get chat-local :path)) + :avatar-file-downloaded (and chat-local + (plist-get chat-local :is_downloading_completed) + t) + :avatar-spec-references-photo (and data + (string-match-p "avatar-" data) + t) + :avatar-spec-initials (and data (string-match-p "cgrad" data) t)))) + +(defun tf/checkpoint-data () + "Snapshot editor-visible state the test waits on and asserts against." + (let* ((root-buffer (get-buffer telega-root-buffer-name)) + (window (and root-buffer (get-buffer-window root-buffer)))) + (append + (list :generation tf/generation + :chats-loaded (and tf/chats-loaded t) + :chats (and (boundp 'telega--chats) + (hash-table-p telega--chats) + (hash-table-count telega--chats)) + :auth (and (boundp 'telega--auth-state) + (format "%S" telega--auth-state)) + :window-start (and window (window-start window)) + :window-end (and window (window-end window)) + :buffer-size (and root-buffer (with-current-buffer root-buffer (buffer-size))) + :selected-buffer (buffer-name (window-buffer (selected-window))) + :graphic (and (display-graphic-p) t)) + (tf/avatar-state)))) + +(defun tf/write-checkpoint () + (setq tf/generation (1+ tf/generation)) + (tf/write-atomic tf/checkpoint-path (concat (json-encode (tf/checkpoint-data)) "\n"))) + +(defun tf/answer-snapshot-request () + "Write a full frame snapshot for the token in the request file." + (when (file-exists-p tf/request-path) + (let* ((token (with-temp-buffer + (insert-file-contents tf/request-path) + (string-trim (buffer-string)))) + (snapshot (and (fboundp 'neomacs--write-frame-snapshot) + (neomacs--write-frame-snapshot tf/snapshot-path t 'json)))) + (unless snapshot + (error "neomacs--write-frame-snapshot is unavailable")) + (tf/write-atomic tf/ready-path token) + (delete-file tf/request-path)))) + +(defun tf/tick () + "Periodic readiness checkpoint + snapshot service; never throws." + (condition-case err + (progn + (tf/write-checkpoint) + (tf/answer-snapshot-request) + (when (file-exists-p tf/quit-path) + (kill-emacs 0))) + (error (tf/fail err)))) + +(tf/guard + (tf/assert-isolation) + (tf/configure-telega-paths) + (tf/mount-pinned-telega) + (tf/record-isolation) + + (add-hook 'telega-chats-fetched-hook + (lambda () + (setq tf/chats-loaded t) + ;; Show the real root buffer in the selected window; the + ;; `-Q -l' startup path can otherwise leave *scratch* selected. + (run-at-time 0 nil (lambda () + (switch-to-buffer telega-root-buffer-name) + (tf/write-checkpoint))))) + + (telega) + + (run-with-timer 0 0.15 #'tf/tick) + + ;; Watchdog: never outlive the harness by much. + (run-with-timer 180 nil + (lambda () + (tf/fail (list 'watchdog :elapsed (- (float-time) tf/started-at)))))) diff --git a/crates/neomacs-gui-tests/fixtures/telega-fixture.md b/crates/neomacs-gui-tests/fixtures/telega-fixture.md new file mode 100644 index 0000000000..e344837d86 --- /dev/null +++ b/crates/neomacs-gui-tests/fixtures/telega-fixture.md @@ -0,0 +1,194 @@ +# Account-free Telega frontend fixture + +Deterministic, offline integration coverage for the real Telega Lisp frontend +running in Neomacs on a harness-owned private display. It implements the Telega +subprocess boundary, not TDLib or MTProto. +Passing these tests does not establish the cause of the reported missing +avatars in a personal session. + +## Seam and architecture + +| Piece | Where | What is real | +| --- | --- | --- | +| Wire protocol | `src/telega_fixture/protocol.rs` | Telega's byte-length framed plist protocol: `send \n\n` in, `event \n\n` out, `N` = UTF-8 **bytes**, `:@extra` reply correlation preserved | +| Mock server | `src/telega_fixture/mock.rs` + `src/bin/neomacs-telega-fixture-mock.rs` | launched by Telega through its real `telega-server-command`; answers `setOption`/`setTdlibParameters`/`setNetworkType`/`setScopeNotificationSettings`/`getOption`/`loadChats`/`downloadFile`/`getBlockedMessageSenders`/`getSavedMessagesTags`/`getBasicGroup` (correlated `ok`/typed replies whenever `:@extra` is present), answers the `-h` version probe, rejects unmodeled requests with `NEOMACS_FIXTURE_UNSUPPORTED`, and rejects unknown option/file/group/control ids with `NEOMACS_FIXTURE_UNKNOWN_*` plus a `violation` log record | +| Scenario | `src/telega_fixture/scenario.rs` | 112 synthetic `chatTypeBasicGroup` chats, lossless generated PNG avatars, same-width positive int64 chat orders so Telega's string sort matches "Synthetic Group 01" first | +| Frontend scenario | `fixtures/telega-fixture-gui.el` | real pinned Telega loaded from the provisioned tree; real process filter, callback dispatch, root buffer, SVG avatar construction, redisplay and PageUp/PageDown scrolling | +| GUI tests | `tests/telega_fixture_gui.rs` | private Xvfb display, fresh HOME/XDG/TMPDIR, owned runtime dir, private process group | + +The tests assert on the rendered frame snapshot (per-row glyphs and image +glyphs) **and** on private-display screenshots of the same checkpoint: every +fully visible synthetic row must start with an avatar image glyph and carry +enough pixels of the expected avatar color in its leading band. Clipped +partial rows are excluded; window-start must advance across PageDown and +return to 1 across PageUp. + +## Scope + +In scope: Telega's subprocess protocol handling, request/response correlation, +update dispatch, chat-list rendering, avatar media construction/embedding, +redisplay of avatars, and paging in Neomacs. + +Out of scope: native TDLib, MTProto, authentication, download semantics, and +anything about a real Telegram account. The mock does not contact Telegram +or another service. Package provisioning may fetch pinned sources before +the scenario starts. The private X11 display uses authenticated local TCP. + +## Isolation + +* Fresh `HOME`, `XDG_CONFIG_HOME/CACHE/DATA/STATE`, `TMPDIR` under + `target/neomacs-gui-tests/tf--/`; Telega's directory, database, + cache, and temp paths are set **before** Telega is loaded. +* Owned `XDG_RUNTIME_DIR` with mode 0700, published as + `/proc//fd/` (the existing neomacs-infra pattern) so long + checkout paths cannot overflow `sockaddr_un`; the test canonicalizes the + published path and asserts it is the owned directory. +* Private Xvfb display from `neomacs_infra::display::start_xvfb`; the tests + assert `DISPLAY`/runtime from `/proc//environ`. `WAYLAND_SOCKET` is + removed (absent, not empty), `DBUS_SESSION_BUS_ADDRESS`, + `EMACSLOADPATH` and related loading overrides are removed. + Xvfb uses a fixture-owned Xauthority cookie rather than the user's display + credentials. +* Package mounting is explicit: the harness passes the provisioned package + directories and the pinned `telega.el` path; the scenario adds exactly those + to `load-path` and loads that file. `package-user-dir` points at a + fixture-owned empty directory and no archive/discovery path is consulted. +* **This is configuration isolation, not a sandbox.** The fixture does not + create a network namespace, seccomp filter, or mount namespace. Filesystem + access and external network access are not denied by the operating system. + Other host environment variables, tools, fonts and graphics libraries can + still affect a run. The scenario asserts the paths it configured. +* Cleanup: the editor is spawned with `process_group(0)`. Teardown writes a + graceful quit request, waits, then signals exactly `kill(-pgid, ...)` for + the recorded group (editor + mock it spawned) and never any process by + name. The private Xvfb is owned and killed by `DisplaySession`. + +## Commands + +```sh +# Provision the pinned Telega (+ visual-fill-column, transient) once; network +# access happens here only. +cargo run -p neomacs-infra --bin infra -- packages preflight telega@20261002.1709 + +# Protocol/scenario/mock unit tests. +cargo nextest run -p neomacs-gui-tests --lib telega + +# Private GUI integration (needs target/release/neomacs). +cargo build --release -p neomacs --bin neomacs +cargo nextest run -p neomacs-gui-tests --test telega_fixture_gui + +# Focused slices. +cargo nextest run -p neomacs-gui-tests --test telega_fixture_gui mock_answers_the_telega_server_version_probe +cargo nextest run -p neomacs-gui-tests --test telega_fixture_gui telega_frontend_renders_synthetic_chats_and_paged_avatars_on_a_private_display +``` + +Artifacts land in `target/neomacs-gui-tests/tf--/`: +`page-top.png`, `page-down-N.png`, `page-up-N.png` (private-display +screenshots), `frame-snapshot.json`, `checkpoint.json`, `mock-log.jsonl`, +`isolation.json`, `neomacs.stderr.log`. A trial run of the paging slice +(`tf-render-paging-*`) produced screenshots with magenta avatar circles at +the leading edge of every visible synthetic row. + +### GUI test inventory + +| Test | Covers | +| --- | --- | +| `mock_answers_the_telega_server_version_probe` | `telega-server -h` version contract | +| `telega_frontend_renders_synthetic_chats_and_paged_avatars_on_a_private_display` | 112 rendered chats, per-row avatar pixels at the line beginning, ≥3 viewports, PageDown advance + bottom, PageUp return to window-start 1, Group 01 first, isolation + zero fixture drift | +| `telega_frontend_ingests_a_delayed_avatar_update_file` | no photo before delivery, explicit `downloadFile` request, `updateFile` ingestion into Telega's file table and cached avatar spec | +| `telega_frontend_repaints_a_delayed_avatar_after_update_file` | `updateFile` must replace initials with photo pixels without a test-side refresh | +| `telega_frontend_replaces_a_chat_photo_without_restarting` | `updateChatPhoto` swaps exactly the first row's photo to the replacement color while other rows keep theirs | +| `fixture_tears_down_its_owned_process_group_and_display` | graceful quit, recorded process group gone (`kill(-pgid, 0)` → ESRCH), authenticated display probe succeeds before teardown and fails afterward, mock recorded `eof`/`stopped` | +| `fixture_cleans_up_after_a_panicking_scenario` | unwinding teardown after a simulated failure still releases the recorded group and the private display | + +## Separate retained-layout regression + +The reduced editor test is independent of the Telega fixture: + +```sh +cargo nextest run -p neomacs-layout-engine --lib \ + forced_window_update_rebuilds_a_mutated_buffer_image_spec +``` + +It mutates a buffer image spec in place, calls `(image-flush SPEC t)` and +`(force-window-update)`, and compares retained geometry with a fresh full +layout and synchronous queries. Before the production change, it failed +because the retained geometry stayed stale. It passes with the typed +`BodyRedisplayRevision` in the retained key and layout freshness tokens. +Presentation-only redisplay requests remain separate, preserving body reuse. + +GNU's `src/window.c`, `Fforce_window_update` and +`window_loop(REDISPLAY_BUFFER_WINDOWS)`, define the all-window, window and +buffer scopes. The buffer walk excludes even active minibuffers, while an +explicit live minibuffer-window target is supported. A successful force also +requests mode-line updates. +The return-value contract is checked against live GNU Emacs by the cx409 +oracle tests. The terminal test uses a mutable space display spec because +TTY frames cannot render avatars. + +The revised Telega fixture's seven GUI tests also passed on the unchanged +release binary, before this production change. An earlier fixture revision +failed delayed repaint, but changes to initialization, protocol responses +and readiness preceded its passing run. That result is not evidence that +this layout change fixes the original Telega report. The reduced layout +regression supplies the failing test for the production change. + +## Research and coverage boundary + +Telega exposes its subprocess through +[`telega-server-command`](https://github.com/zevlg/telega.el/blob/a6abce419828fc63c7698c7d4951614e8d12d2ff/telega-customize.el). +The fixture models the framing and callback boundary in +[`telega-server.el`](https://github.com/zevlg/telega.el/blob/a6abce419828fc63c7698c7d4951614e8d12d2ff/telega-server.el). +This keeps the frontend real while replacing the account-dependent service. + +[Telegram's test accounts](https://core.telegram.org/api/auth#test-accounts) +use remote test data centers. They are useful for service integration, but +do not provide a deterministic offline GUI fixture. A full local MTProto +service would require a much larger authentication/dialog/photo contract +and stock TDLib compatibility. The current seam intentionally tests neither. + +## Validation + +Run protocol/scenario tests, package-pin tests and the GUI command above. +For the shared redisplay change also run: + +```sh +cargo nextest run -p neovm-core --lib force_window_update +NEOVM_ORACLE_MODE=verify NEOVM_FORCE_ORACLE_PATH=/path/to/gnu/emacs \ + cargo nextest run -p neovm-oracle-tests div_cx409_force_window_update +cargo nextest run -p neomacs-tui-tests --test tui \ + force_window_update_repaints_mutated_display_spec_like_gnu +``` + +GUI and TUI integration must use a binary rebuilt from the working tree. +`NEOMACS_GUI_TEST_BINARY`, `NEOMACS_TUI_NEOMACS_BIN`, and the oracle's +`NEOVM_BINARY_PATH` can select it. +Package provisioning is cached separately from each fresh scenario. + +The final post-rebase run used a freshly rebuilt `target/debug/neomacs` for final +integration checks. Results: 23 fixture unit tests, one package-pin test, +15 core redisplay tests, 117 layout validity/incremental/freshness tests, +two live GNU oracle tests, two paired terminal tests (forced display-spec +repaint and the new main-branch terminal-output test), and all seven private +GUI tests passed. Formatting passed. Clippy found no warnings in the new +fixture; strict Clippy was blocked by eight findings in unchanged +infrastructure files. + +A repeatability check ran the 23 fixture unit tests, seven private GUI tests, +two live GNU oracle tests, and one paired terminal test three times each +with `--stress-count 3 --retries 0`: all iterations passed. This establishes +repeatability in the current environment, not portability across every host +or freedom from all timing-related failures. Each GUI repetition starts a +new editor, mock, display, and scenario directory. After review strengthened +the teardown probes to preserve and authenticate with the private cookie, +both normal and panic cleanup tests passed three further repetitions without +retries. The final 15 core tests also passed three repetitions without retries. + +## Boundaries + +The fixture never reads `~/.config/emacs`, `~/.emacs.d`, `~/.telega`, an +account, database, cache, profile photo, captured conversation, user Emacs +server, user display, or user desktop focus. All chats, titles, and avatar +pixels are generated in-tree. The archived probes under +`tmp/telega-avatar-probes/` are not part of the suite and are not restored by +this work. diff --git a/crates/neomacs-gui-tests/src/bin/neomacs-telega-fixture-mock.rs b/crates/neomacs-gui-tests/src/bin/neomacs-telega-fixture-mock.rs new file mode 100644 index 0000000000..b7a3964699 --- /dev/null +++ b/crates/neomacs-gui-tests/src/bin/neomacs-telega-fixture-mock.rs @@ -0,0 +1,55 @@ +//! Offline `telega-server` stand-in used by the account-free Telega GUI +//! fixture. Telega launches this binary through its real +//! `telega-server-command` customization; the binary speaks the real +//! stdin/stdout protocol and serves the synthetic scenario below +//! `NEOMACS_TELEGA_FIXTURE_ROOT`. +//! +//! It never opens a network connection, never reads `~/.telega` or any other +//! personal path, and rejects unmodeled requests explicitly. + +use std::process::ExitCode; + +use neomacs_gui_tests::telega_fixture::mock::{ + ControlReader, FIXTURE_ROOT_ENV, FIXTURE_SCENARIO_ENV, FixtureLog, FixturePaths, FixtureServer, + Invocation, parse_invocation, run_server, +}; +use neomacs_gui_tests::telega_fixture::scenario::{AvatarAvailability, FixtureScenario}; + +fn main() -> ExitCode { + let args = std::env::args().skip(1).collect::>(); + match parse_invocation(&args) { + // Telega's `telega-server -h` version probe: exit 0 before touching + // the environment so a missing fixture root is not misreported. + Invocation::Probe(output) => { + print!("{output}"); + ExitCode::SUCCESS + } + Invocation::Error(message) => { + eprintln!("telega-fixture-mock: {message}"); + ExitCode::from(2) + } + Invocation::Serve => match serve() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("telega-fixture-mock: {error}"); + ExitCode::FAILURE + } + }, + } +} + +fn serve() -> Result<(), String> { + let root = std::env::var(FIXTURE_ROOT_ENV) + .map_err(|_| format!("{FIXTURE_ROOT_ENV} is not set; refusing to serve"))?; + let scenario_name = std::env::var(FIXTURE_SCENARIO_ENV).unwrap_or_else(|_| "ready".to_string()); + let availability = AvatarAvailability::from_name(&scenario_name) + .ok_or_else(|| format!("unknown fixture scenario `{scenario_name}`"))?; + + let paths = FixturePaths::new(root); + paths.create().map_err(|error| error.to_string())?; + let log = FixtureLog::open(&paths.log).map_err(|error| error.to_string())?; + let scenario = FixtureScenario::new(&paths.photos, availability); + let server = FixtureServer::new(scenario, log).map_err(|error| error.to_string())?; + let control = ControlReader::new(paths.control.clone()); + run_server(server, std::io::stdout().lock(), control).map_err(|error| error.to_string()) +} diff --git a/crates/neomacs-gui-tests/src/lib.rs b/crates/neomacs-gui-tests/src/lib.rs index fc33ea6c9d..1f3e47b7c6 100644 --- a/crates/neomacs-gui-tests/src/lib.rs +++ b/crates/neomacs-gui-tests/src/lib.rs @@ -6,6 +6,9 @@ use std::fs; pub mod interaction; +/// Account-free Telega frontend fixture: deterministic synthetic chats, +/// locally generated avatars, and a `telega-server`-protocol mock process. +pub mod telega_fixture; use std::io; use std::io::Read; diff --git a/crates/neomacs-gui-tests/src/telega_fixture/mock.rs b/crates/neomacs-gui-tests/src/telega_fixture/mock.rs new file mode 100644 index 0000000000..6c59832d60 --- /dev/null +++ b/crates/neomacs-gui-tests/src/telega_fixture/mock.rs @@ -0,0 +1,1238 @@ +//! The fixture's `telega-server` process: a deterministic, offline mock that +//! Telega launches through its real `telega-server-command` customization. +//! +//! The mock speaks the real wire protocol ([`super::protocol`]), emits real +//! TDLib-shaped events for the synthetic [`super::scenario`], and answers +//! every request Telega makes. It never opens a socket, reads a personal +//! path, or touches a Telegram account/database/cache. +//! +//! Requests the fixture does not model are rejected with an explicit TL +//! `error` carrying `NEOMACS_FIXTURE_UNSUPPORTED` and are recorded in the +//! structured log, so fixture drift fails the test loudly instead of +//! pretending success. + +use std::collections::BTreeMap; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, BufReader, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::thread; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use super::protocol::{ + AuthorizationState, BasicGroupObject, ChatList, ChatObject, ClientFrame, FileObject, + PhotoObject, ProtocolError, ServerEvent, ServerRequest, decode_request, read_client_frame, + write_server_event, +}; +use super::scenario::{AvatarAvailability, FixtureScenario}; + +/// Version reported by the `-h` probe. Must be >= `telega-server-min-version` +/// (Telega 0.8.671 requires "0.7.7") so startup never prompts to rebuild. +pub const MOCK_SERVER_VERSION: &str = "1.0.0"; + +/// Deterministic remote unix time reported for `getOption :unix_time`. +pub const FIXTURE_UNIX_TIME: i64 = 1_700_000_000; + +/// Environment variable Telega's process inherits with the fixture root. +pub const FIXTURE_ROOT_ENV: &str = "NEOMACS_TELEGA_FIXTURE_ROOT"; + +/// Environment variable selecting the scenario behavior. +pub const FIXTURE_SCENARIO_ENV: &str = "NEOMACS_TELEGA_FIXTURE_SCENARIO"; + +/// `-h` output, matching `telega-server`'s first line contract. +pub fn version_probe_output() -> String { + format!( + "Version {MOCK_SERVER_VERSION}\nusage: telega-fixture-mock [-O OPT] [-v LVL] [-l FILE] [-h]\n" + ) +} + +/// One structured log line. Tests poll this file for readiness checkpoints; +/// every line is written with a single `write` + flush so a reader never +/// observes a torn record. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct LogLine { + /// `ready`, `request`, `event`, `control`, `unsupported`, `note`. + pub record: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub type_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extra: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chat_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +impl LogLine { + fn new(record: &str) -> Self { + Self { + record: record.to_string(), + stage: None, + type_name: None, + extra: None, + file_id: None, + chat_id: None, + command: None, + message: None, + } + } + + pub fn ready(stage: &str) -> Self { + Self { + stage: Some(stage.to_string()), + ..Self::new("ready") + } + } + + pub fn request(type_name: &str, extra: Option) -> Self { + Self { + type_name: Some(type_name.to_string()), + extra, + ..Self::new("request") + } + } + + pub fn event(type_name: &str, extra: Option) -> Self { + Self { + type_name: Some(type_name.to_string()), + extra, + ..Self::new("event") + } + } + + pub fn unsupported(type_name: &str, extra: Option) -> Self { + Self { + type_name: Some(type_name.to_string()), + extra, + ..Self::new("unsupported") + } + } + + /// A request that is in the supported vocabulary but names an unknown + /// option/file/group/chat id. Unlike `unsupported` (fixture drift), this + /// is an explicit error reply for a bad address. + pub fn violation(message: &str) -> Self { + Self { + message: Some(message.to_string()), + ..Self::new("violation") + } + } + + pub fn note(message: &str) -> Self { + Self { + message: Some(message.to_string()), + ..Self::new("note") + } + } +} + +/// Commands the test writes to `/control.jsonl` to drive explicit +/// checkpoints (file delivery, photo replacement). +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(tag = "command", rename_all = "snake_case")] +pub enum ControlCommand { + /// Announce the downloaded avatar file for FILE_ID via `updateFile`. + DeliverFile { file_id: i64 }, + /// Replace CHAT_ID's photo with the scenario's replacement image. + ReplacePhoto { chat_id: i64 }, + /// Exit the mock cleanly. + Stop, +} + +/// Appends newline-delimited JSON records for the test to poll. +pub struct FixtureLog { + file: File, +} + +impl FixtureLog { + pub fn open(path: &Path) -> io::Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + Ok(Self { + file: OpenOptions::new().create(true).append(true).open(path)?, + }) + } + + pub fn write(&mut self, line: &LogLine) -> io::Result<()> { + let mut encoded = serde_json::to_vec(line).map_err(io::Error::other)?; + encoded.push(b'\n'); + self.file.write_all(&encoded)?; + self.file.flush() + } +} + +/// The stateful fixture server. Pure with respect to the wire: `handle` +/// returns the events to write, so unit tests drive it directly. +pub struct FixtureServer { + pub scenario: FixtureScenario, + log: FixtureLog, + auth_ready: bool, + main_loads: u32, + /// Avatars written because Telega asked to download them. + materialized: BTreeMap, + /// Downloaded (delivered) files by id. + delivered: BTreeMap, + /// Replacement photos already announced, by chat id. + replaced: BTreeMap, +} + +impl FixtureServer { + pub fn new(scenario: FixtureScenario, mut log: FixtureLog) -> io::Result { + scenario.materialize_ready_avatars()?; + log.write(&LogLine::ready("started")).ok(); + Ok(Self { + scenario, + log, + auth_ready: false, + main_loads: 0, + materialized: BTreeMap::new(), + delivered: BTreeMap::new(), + replaced: BTreeMap::new(), + }) + } + + pub fn log(&mut self) -> &mut FixtureLog { + &mut self.log + } + + /// The event Telega must receive before it sends `setTdlibParameters`. + pub fn startup_events(&mut self) -> Vec { + vec![ServerEvent::UpdateAuthorizationState { + state: AuthorizationState::WaitTdlibParameters, + }] + } + + fn avatar_file(&self, file_id: i64) -> Option { + let chat = self.scenario.chat_for_file(file_id)?; + let is_replacement = chat.replacement_file_id == file_id; + let image = if is_replacement { + &chat.replacement + } else { + &chat.avatar + }; + // A replacement photo is always handed over ready. Initial avatars + // follow the scenario: ready on disk, or available only once the + // fixture has delivered the completed download. + let ready_on_disk = + is_replacement || self.scenario.availability == AvatarAvailability::ReadyOnDisk; + let delivered = self.delivered.contains_key(&file_id); + let available = delivered || (ready_on_disk && image.exists()); + Some(FileObject { + id: file_id, + size: (super::scenario::AVATAR_PIXELS * super::scenario::AVATAR_PIXELS * 3) as i64, + local_path: if available { + image.path.to_string_lossy().into_owned() + } else { + String::new() + }, + local_can_be_downloaded: !available, + is_downloading_active: self.materialized.contains_key(&file_id) && !delivered, + is_downloading_completed: available, + }) + } + + /// `chatPhoto` for the chat's *initial* avatar (or `None` when the chat + /// has no photo this scenario). + fn photo_for(&self, chat: &super::scenario::SyntheticChat) -> PhotoObject { + let file = self + .avatar_file(chat.file_id) + .expect("scenario chat has an avatar file"); + PhotoObject { + small: file.clone(), + large: file, + } + } + + fn replacement_photo_for(&mut self, chat_id: i64) -> Option { + let chat = self.scenario.chat(chat_id)?; + let file_id = chat.replacement_file_id; + let file = self + .avatar_file(file_id) + .expect("scenario chat has a replacement avatar file"); + self.replaced.insert(chat_id, file.clone()); + Some(PhotoObject { + small: file.clone(), + large: file, + }) + } + + fn chat_events(&mut self) -> Vec { + let mut events = Vec::new(); + for chat in &self.scenario.chats { + let object = ChatObject { + id: chat.id, + title: chat.title.clone(), + basic_group_id: chat.id, + photo: Some(self.photo_for(chat)), + positions: vec![(ChatList::Main, chat.order)], + }; + events.push(ServerEvent::UpdateNewChat { chat: object }); + events.push(ServerEvent::ChatPosition { + chat_id: chat.id, + list: ChatList::Main, + order: chat.order, + }); + } + events + } + + /// Handle one decoded request; returns the events to write back. + pub fn handle(&mut self, request: ServerRequest) -> Vec { + self.log + .write(&LogLine::request(request.type_name(), request.extra())) + .ok(); + let events = self.dispatch(request); + self.record_events(&events); + events + } + + /// Record the events a handler is about to put on the wire, so a test can + /// use the log as a readiness checkpoint. + fn record_events(&mut self, events: &[ServerEvent]) { + for event in events { + let (type_name, extra) = event_summary(event); + let mut line = LogLine::event(type_name, extra); + line.file_id = event_file_id(event); + line.chat_id = event_chat_id(event); + self.log.write(&line).ok(); + } + } + + fn dispatch(&mut self, request: ServerRequest) -> Vec { + match request { + ServerRequest::SetTdlibParameters { + database_directory, + files_directory, + extra, + } => { + self.log + .write(&LogLine::note(&format!( + "tdlib database={database_directory} files={files_directory}" + ))) + .ok(); + self.auth_ready = true; + // The authorization-state event is unsolicited; the call + // itself is only answered when Telega used a correlating call. + let mut events = vec![ServerEvent::UpdateAuthorizationState { + state: AuthorizationState::Ready, + }]; + if let Some(extra) = extra { + events.insert(0, ServerEvent::Ok { extra: Some(extra) }); + } + events + } + // Setters are `ok` calls when Telega used `telega-server--call` + // (which always injects `:@extra`); a plain `send` has no extra + // and expects no reply. + ServerRequest::SetOption { extra, .. } + | ServerRequest::SetNetworkType { extra } + | ServerRequest::SetScopeNotificationSettings { extra } => extra + .map_or_else(Vec::new, |extra| { + vec![ServerEvent::Ok { extra: Some(extra) }] + }), + ServerRequest::GetOption { name, extra } => { + if name == "unix_time" { + vec![ServerEvent::GetOptionInteger { + name, + value: FIXTURE_UNIX_TIME, + extra, + }] + } else { + self.log + .write(&LogLine::violation(&format!( + "getOption named unknown option `{name}`" + ))) + .ok(); + vec![ServerEvent::Error { + code: 400, + message: format!("NEOMACS_FIXTURE_UNKNOWN_OPTION {name}"), + extra, + }] + } + } + ServerRequest::LoadChats { + chat_list, extra, .. + } => match chat_list { + ChatList::Main if self.main_loads == 0 => { + self.main_loads += 1; + let mut events = self.chat_events(); + events.push(ServerEvent::Ok { extra }); + events + } + _ => { + // TDLib signals "all chats have been loaded" with a 404. + vec![ServerEvent::Error { + code: 404, + message: "Chats not found".to_string(), + extra, + }] + } + }, + ServerRequest::DownloadFile { file_id, extra } => { + let Some(chat) = self.scenario.chat_for_file(file_id).cloned() else { + self.log + .write(&LogLine::violation(&format!( + "downloadFile named unknown file id {file_id}" + ))) + .ok(); + return vec![ServerEvent::Error { + code: 400, + message: format!("NEOMACS_FIXTURE_UNKNOWN_FILE {file_id}"), + extra, + }]; + }; + let mut current = self + .avatar_file(file_id) + .expect("known file belongs to a scenario chat"); + if current.is_downloading_completed { + // TDLib returns an already-available file unchanged; it + // never fabricates a download for bytes it already has. + return vec![ServerEvent::File { + file: current, + extra, + }]; + } + // Delayed availability: the bytes appear only when Telega + // actually asks for the download. + let source = if chat.file_id == file_id { + &chat.avatar + } else { + &chat.replacement + }; + if let Err(error) = source.write() { + self.log + .write(&LogLine::note(&format!( + "failed to materialize avatar {file_id}: {error}" + ))) + .ok(); + } + self.materialized.insert(file_id, source.path.clone()); + current.is_downloading_active = true; + current.local_path = String::new(); + vec![ServerEvent::File { + file: current, + extra, + }] + } + ServerRequest::GetBlockedMessageSenders { extra } => { + vec![ServerEvent::BlockedMessageSenders { extra }] + } + ServerRequest::GetSavedMessagesTags { extra } => { + vec![ServerEvent::SavedMessagesTags { extra }] + } + ServerRequest::GetBasicGroup { + basic_group_id, + extra, + } => { + if self.scenario.chat(basic_group_id).is_none() { + self.log + .write(&LogLine::violation(&format!( + "getBasicGroup named unknown group id {basic_group_id}" + ))) + .ok(); + return vec![ServerEvent::Error { + code: 404, + message: format!("NEOMACS_FIXTURE_UNKNOWN_GROUP {basic_group_id}"), + extra, + }]; + } + vec![ServerEvent::BasicGroup { + group: BasicGroupObject { + id: basic_group_id, + member_count: 3, + is_active: true, + is_creator: false, + }, + extra, + }] + } + ServerRequest::Unsupported { type_name, extra } => { + self.log + .write(&LogLine::unsupported(&type_name, extra)) + .ok(); + vec![ServerEvent::Error { + code: 400, + message: format!("NEOMACS_FIXTURE_UNSUPPORTED request {type_name}"), + extra, + }] + } + } + } + + /// Apply a control command; returns the events to write. + pub fn apply_control(&mut self, command: ControlCommand) -> Vec { + let events = self.dispatch_control(command); + self.record_events(&events); + events + } + + fn dispatch_control(&mut self, command: ControlCommand) -> Vec { + match command { + ControlCommand::DeliverFile { file_id } => { + let Some(chat) = self.scenario.chat_for_file(file_id).cloned() else { + self.log + .write(&LogLine::violation(&format!( + "control deliver_file named unknown file id {file_id}" + ))) + .ok(); + return vec![ServerEvent::Error { + code: 400, + message: format!("NEOMACS_FIXTURE_UNKNOWN_FILE {file_id}"), + extra: None, + }]; + }; + let image = if chat.file_id == file_id { + &chat.avatar + } else { + &chat.replacement + }; + if !image.exists() && image.write().is_err() { + return Vec::new(); + } + let file = FileObject { + id: file_id, + size: (super::scenario::AVATAR_PIXELS * super::scenario::AVATAR_PIXELS * 3) + as i64, + local_path: image.path.to_string_lossy().into_owned(), + local_can_be_downloaded: false, + is_downloading_active: false, + is_downloading_completed: true, + }; + self.delivered.insert(file_id, file.clone()); + vec![ServerEvent::UpdateFile { file }] + } + ControlCommand::ReplacePhoto { chat_id } => { + let Some(chat) = self.scenario.chat(chat_id).cloned() else { + self.log + .write(&LogLine::violation(&format!( + "control replace_photo named unknown chat id {chat_id}" + ))) + .ok(); + return vec![ServerEvent::Error { + code: 404, + message: format!("NEOMACS_FIXTURE_UNKNOWN_CHAT {chat_id}"), + extra: None, + }]; + }; + if !chat.replacement.exists() && chat.replacement.write().is_err() { + return Vec::new(); + } + let Some(photo) = self.replacement_photo_for(chat_id) else { + return Vec::new(); + }; + vec![ServerEvent::UpdateChatPhoto { + chat_id, + photo: Some(photo), + }] + } + ControlCommand::Stop => Vec::new(), + } + } + + pub fn auth_ready(&self) -> bool { + self.auth_ready + } +} + +fn event_summary(event: &ServerEvent) -> (&'static str, Option) { + match event { + ServerEvent::UpdateAuthorizationState { .. } => ("updateAuthorizationState", None), + ServerEvent::GetOptionInteger { extra, .. } => ("optionValueInteger", *extra), + ServerEvent::Ok { extra } => ("ok", *extra), + ServerEvent::Error { extra, .. } => ("error", *extra), + ServerEvent::UpdateFile { .. } => ("updateFile", None), + ServerEvent::BlockedMessageSenders { extra } => ("messageSenders", *extra), + ServerEvent::SavedMessagesTags { extra } => ("savedMessagesTags", *extra), + ServerEvent::UpdateNewChat { .. } => ("updateNewChat", None), + ServerEvent::ChatPosition { .. } => ("updateChatPosition", None), + ServerEvent::UpdateChatPhoto { .. } => ("updateChatPhoto", None), + ServerEvent::File { extra, .. } => ("file", *extra), + ServerEvent::BasicGroup { extra, .. } => ("basicGroup", *extra), + } +} + +fn event_file_id(event: &ServerEvent) -> Option { + match event { + ServerEvent::UpdateFile { file } | ServerEvent::File { file, .. } => Some(file.id), + _ => None, + } +} + +fn event_chat_id(event: &ServerEvent) -> Option { + match event { + ServerEvent::UpdateChatPhoto { chat_id, .. } + | ServerEvent::ChatPosition { chat_id, .. } => Some(*chat_id), + _ => None, + } +} + +/// Reads control commands appended to `path` since `offset`. +pub struct ControlReader { + path: PathBuf, + offset: u64, +} + +impl ControlReader { + pub fn new(path: impl Into) -> Self { + Self { + path: path.into(), + offset: 0, + } + } + + /// Return every command appended since the previous call. + pub fn poll(&mut self) -> io::Result> { + let mut file = match File::open(&self.path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error), + }; + let length = file.metadata()?.len(); + if length < self.offset { + // The test truncated/rewrote the file; restart at 0. + self.offset = 0; + } + if length == self.offset { + return Ok(Vec::new()); + } + file.seek(SeekFrom::Start(self.offset))?; + let mut appended = String::new(); + file.read_to_string(&mut appended)?; + self.offset = length; + let mut commands = Vec::new(); + for line in appended.lines() { + if line.trim().is_empty() { + continue; + } + match serde_json::from_str::(line) { + Ok(command) => commands.push(command), + Err(error) => { + return Err(io::Error::other(format!( + "invalid fixture control line `{line}`: {error}" + ))); + } + } + } + Ok(commands) + } +} + +/// Run the mock against real stdin/stdout until EOF or a `stop` control +/// command. A reader thread decouples blocking stdin reads from control +/// polling so checkpoints never depend on fixed sleeps. +pub fn run_server( + mut server: FixtureServer, + stdout: impl Write, + control: ControlReader, +) -> io::Result<()> { + let mut stdout = stdout; + // A dropped sender (clean EOF) is distinct from a protocol error, which + // the main loop records as a fixture violation before terminating. + let (sender, receiver) = mpsc::channel::>(); + thread::spawn(move || { + let stdin = io::stdin(); + let mut reader = BufReader::new(stdin.lock()); + loop { + match read_client_frame(&mut reader) { + Ok(Some(frame)) => { + if sender.send(Ok(frame)).is_err() { + break; + } + } + Ok(None) => break, + Err(error) => { + let _ = sender.send(Err(error.to_string())); + break; + } + } + } + }); + + for event in server.startup_events() { + write_server_event(&mut stdout, &event).map_err(protocol_io)?; + } + server + .log() + .write(&LogLine::ready("auth_probe_sent")) + .map_err(io::Error::other)?; + + let mut control = control; + loop { + match receiver.recv_timeout(Duration::from_millis(25)) { + Ok(Ok(frame)) => { + let request = match decode_request(&frame.payload) { + Ok(request) => request, + Err(error) => { + server + .log() + .write(&LogLine::violation(&format!( + "undecodable request: {error}" + ))) + .ok(); + return Err(io::Error::other(error.to_string())); + } + }; + // Unmodeled requests get an explicit error event and a + // recorded violation; the mock keeps serving so the test can + // report every drift instead of only the first one. + for event in server.handle(request) { + write_server_event(&mut stdout, &event).map_err(protocol_io)?; + } + } + Ok(Err(message)) => { + server + .log() + .write(&LogLine::violation(&format!( + "telega-server protocol error: {message}" + ))) + .ok(); + return Err(io::Error::other(message)); + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => break, + } + let commands = match control.poll() { + Ok(commands) => commands, + Err(error) => { + server + .log() + .write(&LogLine::violation(&format!( + "invalid control channel: {error}" + ))) + .ok(); + return Err(error); + } + }; + for command in commands { + let is_stop = matches!(command, ControlCommand::Stop); + for event in server.apply_control(command) { + write_server_event(&mut stdout, &event).map_err(protocol_io)?; + } + if is_stop { + server + .log() + .write(&LogLine::ready("stopped")) + .map_err(io::Error::other)?; + return Ok(()); + } + } + } + server + .log() + .write(&LogLine::ready("eof")) + .map_err(io::Error::other)?; + Ok(()) +} + +fn protocol_io(error: ProtocolError) -> io::Error { + io::Error::other(error.to_string()) +} + +/// Command-line handling: accept the flags Telega passes (`-v 0`, +/// `-O `, `-l `, `-z`, `-L `) and answer the version probe. +pub enum Invocation { + Probe(String), + Serve, + Error(String), +} + +pub fn parse_invocation(args: &[String]) -> Invocation { + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "-h" => return Invocation::Probe(version_probe_output()), + "-z" => index += 1, + "-v" | "-O" | "-L" | "-l" => { + if index + 1 >= args.len() { + return Invocation::Error(format!("missing value for {}", args[index])); + } + index += 2; + } + other => return Invocation::Error(format!("unsupported argument `{other}`")), + } + } + Invocation::Serve +} + +/// Root layout shared between the GUI test (writer) and the mock (reader). +#[derive(Clone, Debug)] +pub struct FixturePaths { + pub root: PathBuf, + pub photos: PathBuf, + pub log: PathBuf, + pub control: PathBuf, +} + +impl FixturePaths { + pub fn new(root: impl Into) -> Self { + let root = root.into(); + Self { + photos: root.join("telega-db/photos"), + log: root.join("mock-log.jsonl"), + control: root.join("control.jsonl"), + root, + } + } + + pub fn create(&self) -> io::Result<()> { + fs::create_dir_all(&self.photos)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::telega_fixture::protocol::print_expr; + use crate::telega_fixture::scenario::{AVATAR_COLOR, AvatarAvailability, FixtureScenario}; + use std::path::Path; + + fn temp_root(name: &str) -> PathBuf { + let root = + std::env::temp_dir().join(format!("neomacs-telega-mock-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("create temp root"); + root + } + + fn server(name: &str, availability: AvatarAvailability) -> (FixtureServer, FixturePaths) { + let paths = FixturePaths::new(temp_root(name)); + paths.create().expect("fixture paths"); + let scenario = FixtureScenario::new(&paths.photos, availability); + let log = FixtureLog::open(&paths.log).expect("open log"); + let mut server = FixtureServer::new(scenario, log).expect("create server"); + let startup = server.startup_events(); + assert_eq!( + startup, + vec![ServerEvent::UpdateAuthorizationState { + state: AuthorizationState::WaitTdlibParameters + }] + ); + (server, paths) + } + + fn read_log(paths: &FixturePaths) -> Vec { + fs::read_to_string(&paths.log) + .unwrap_or_default() + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect() + } + + #[test] + fn handshake_waits_for_set_tdlib_parameters_then_reports_ready() { + let (mut server, paths) = server("handshake", AvatarAvailability::ReadyOnDisk); + assert!(!server.auth_ready()); + let events = server.handle(ServerRequest::SetOption { + name: "online".to_string(), + extra: None, + }); + assert!(events.is_empty(), "a plain send expects no reply"); + assert!(!server.auth_ready()); + + let events = server.handle(ServerRequest::SetTdlibParameters { + database_directory: "/fixture/db".to_string(), + files_directory: "/fixture/files".to_string(), + extra: None, + }); + assert!(server.auth_ready()); + assert_eq!( + events, + vec![ServerEvent::UpdateAuthorizationState { + state: AuthorizationState::Ready + }] + ); + let log = read_log(&paths); + assert!( + log.iter() + .any(|line| line.record == "ready" && line.stage.as_deref() == Some("started")) + ); + assert!(log.iter().any(|line| line.record == "request" + && line.type_name.as_deref() == Some("setTdlibParameters"))); + let _ = fs::remove_dir_all(&paths.root); + } + + #[test] + fn correlated_setter_calls_are_acknowledged_with_ok() { + let (mut server, paths) = server("setters", AvatarAvailability::ReadyOnDisk); + // `telega-server--call` injects `:@extra`; the fixture must answer so + // the synchronous call returns instead of waiting for its timeout. + for request in [ + ServerRequest::SetOption { + name: "online".to_string(), + extra: Some(41), + }, + ServerRequest::SetNetworkType { extra: Some(42) }, + ServerRequest::SetScopeNotificationSettings { extra: Some(43) }, + ] { + let extra = request.extra(); + assert_eq!(server.handle(request), vec![ServerEvent::Ok { extra }]); + } + // Plain sends stay unanswered. + assert!( + server + .handle(ServerRequest::SetOption { + name: "language_pack_id".to_string(), + extra: None, + }) + .is_empty() + ); + + // setTdlibParameters keeps its unsolicited authorization event and + // additionally acknowledges a correlating call. + let events = server.handle(ServerRequest::SetTdlibParameters { + database_directory: "/fixture/db".to_string(), + files_directory: "/fixture/files".to_string(), + extra: Some(44), + }); + assert_eq!( + events, + vec![ + ServerEvent::Ok { extra: Some(44) }, + ServerEvent::UpdateAuthorizationState { + state: AuthorizationState::Ready + } + ] + ); + let _ = fs::remove_dir_all(&paths.root); + } + + #[test] + fn unix_time_is_answered_with_a_string_valued_option_value_integer() { + let (mut server, paths) = server("unix-time", AvatarAvailability::ReadyOnDisk); + let events = server.handle(ServerRequest::GetOption { + name: "unix_time".to_string(), + extra: Some(7), + }); + assert_eq!( + events, + vec![ServerEvent::GetOptionInteger { + name: "unix_time".to_string(), + value: FIXTURE_UNIX_TIME, + extra: Some(7), + }] + ); + assert_eq!( + print_expr(&events[0].to_lisp()), + format!( + r#"(:@type "optionValueInteger" :value "{}" :@extra 7)"#, + FIXTURE_UNIX_TIME + ) + ); + let _ = fs::remove_dir_all(&paths.root); + } + + #[test] + fn unknown_option_file_group_and_control_ids_are_rejected_and_recorded() { + let (mut server, paths) = server("unknown-ids", AvatarAvailability::ReadyOnDisk); + + let events = server.handle(ServerRequest::GetOption { + name: "made_up_option".to_string(), + extra: Some(1), + }); + assert_eq!( + events, + vec![ServerEvent::Error { + code: 400, + message: "NEOMACS_FIXTURE_UNKNOWN_OPTION made_up_option".to_string(), + extra: Some(1), + }] + ); + + let events = server.handle(ServerRequest::DownloadFile { + file_id: 999_999, + extra: Some(2), + }); + assert_eq!( + events, + vec![ServerEvent::Error { + code: 400, + message: "NEOMACS_FIXTURE_UNKNOWN_FILE 999999".to_string(), + extra: Some(2), + }] + ); + + let events = server.handle(ServerRequest::GetBasicGroup { + basic_group_id: 424_242, + extra: Some(3), + }); + assert_eq!( + events, + vec![ServerEvent::Error { + code: 404, + message: "NEOMACS_FIXTURE_UNKNOWN_GROUP 424242".to_string(), + extra: Some(3), + }] + ); + + let events = server.apply_control(ControlCommand::DeliverFile { file_id: 5_001_000 }); + assert_eq!( + events, + vec![ServerEvent::Error { + code: 400, + message: "NEOMACS_FIXTURE_UNKNOWN_FILE 5001000".to_string(), + extra: None, + }] + ); + let events = server.apply_control(ControlCommand::ReplacePhoto { chat_id: 77 }); + assert_eq!( + events, + vec![ServerEvent::Error { + code: 404, + message: "NEOMACS_FIXTURE_UNKNOWN_CHAT 77".to_string(), + extra: None, + }] + ); + + let violations: Vec<_> = read_log(&paths) + .into_iter() + .filter(|line| line.record == "violation") + .collect(); + assert_eq!( + violations.len(), + 5, + "every rejected address must be recorded: {violations:?}" + ); + let _ = fs::remove_dir_all(&paths.root); + } + + #[test] + fn first_main_load_returns_synthetic_chats_and_later_loads_report_exhausted() { + let (mut server, paths) = server("chats", AvatarAvailability::ReadyOnDisk); + let events = server.handle(ServerRequest::LoadChats { + chat_list: ChatList::Main, + limit: 1000, + extra: Some(3), + }); + let new_chats = events + .iter() + .filter(|event| matches!(event, ServerEvent::UpdateNewChat { .. })) + .count(); + assert_eq!(new_chats, super::super::scenario::CHAT_COUNT); + assert_eq!(events.last(), Some(&ServerEvent::Ok { extra: Some(3) })); + // Every chat is in the main list, so `is-known` matches and the root + // buffer can render it. + assert!(events.iter().any(|event| matches!( + event, + ServerEvent::ChatPosition { + list: ChatList::Main, + .. + } + ))); + + let events = server.handle(ServerRequest::LoadChats { + chat_list: ChatList::Main, + limit: 1000, + extra: Some(4), + }); + assert_eq!( + events, + vec![ServerEvent::Error { + code: 404, + message: "Chats not found".to_string(), + extra: Some(4) + }] + ); + let _ = fs::remove_dir_all(&paths.root); + } + + #[test] + fn ready_avatars_are_reported_downloaded_with_a_local_path() { + let (mut server, paths) = server("ready-avatar", AvatarAvailability::ReadyOnDisk); + server.handle(ServerRequest::LoadChats { + chat_list: ChatList::Main, + limit: 1000, + extra: Some(1), + }); + let chat = server.scenario.chats[0].clone(); + // A file that is already available is returned unchanged, never + // restarted as a download. + let events = server.handle(ServerRequest::DownloadFile { + file_id: chat.file_id, + extra: Some(9), + }); + let ServerEvent::File { file, extra } = &events[0] else { + panic!("expected a file reply: {events:?}"); + }; + assert_eq!(*extra, Some(9)); + assert!(file.is_downloading_completed); + assert!(!file.is_downloading_active); + assert_eq!(file.local_path, chat.avatar.path.to_string_lossy()); + assert!(Path::new(&file.local_path).is_file()); + let _ = fs::remove_dir_all(&paths.root); + } + + #[test] + fn delayed_avatar_is_not_available_until_the_download_completes() { + let (mut server, paths) = + server("delayed-avatar", AvatarAvailability::DelayedUntilDownload); + let loaded = server.handle(ServerRequest::LoadChats { + chat_list: ChatList::Main, + limit: 1000, + extra: Some(1), + }); + let chat = server.scenario.chats[0].clone(); + let advertised = loaded + .iter() + .find_map(|event| match event { + ServerEvent::UpdateNewChat { chat } => Some(chat), + _ => None, + }) + .and_then(|chat| chat.photo.as_ref()) + .expect("every synthetic chat advertises a photo"); + assert!( + !advertised.small.is_downloading_completed && advertised.small.local_path.is_empty(), + "a delayed avatar must start without a local path" + ); + assert!( + !chat.avatar.exists(), + "delayed avatar bytes must not exist before the download request" + ); + + // Telega asks to download; the fixture materializes the bytes and + // marks the download active, but does NOT announce completion yet. + let started = server.handle(ServerRequest::DownloadFile { + file_id: chat.file_id, + extra: Some(3), + }); + let ServerEvent::File { file: started, .. } = &started[0] else { + panic!("expected a file reply"); + }; + assert!(started.is_downloading_active); + assert!(!started.is_downloading_completed); + assert!(started.local_path.is_empty()); + assert!( + chat.avatar.exists(), + "download request materializes the file" + ); + + let events = server.apply_control(ControlCommand::DeliverFile { + file_id: chat.file_id, + }); + let ServerEvent::UpdateFile { file } = &events[0] else { + panic!("expected updateFile: {events:?}"); + }; + assert!(file.is_downloading_completed); + assert_eq!(file.local_path, chat.avatar.path.to_string_lossy()); + + // After delivery the file reports completed on any later request. + let repeat = server.handle(ServerRequest::DownloadFile { + file_id: chat.file_id, + extra: Some(4), + }); + let ServerEvent::File { file: repeat, .. } = &repeat[0] else { + panic!("expected a file reply"); + }; + assert!(repeat.is_downloading_completed && !repeat.is_downloading_active); + + let log = read_log(&paths); + assert!(log.iter().any(|line| line.record == "event" + && line.type_name.as_deref() == Some("updateFile") + && line.file_id == Some(chat.file_id))); + let _ = fs::remove_dir_all(&paths.root); + } + + #[test] + fn replacement_photo_is_a_new_file_and_reported_through_update_chat_photo() { + let (mut server, paths) = server("replacement", AvatarAvailability::ReadyOnDisk); + server.handle(ServerRequest::LoadChats { + chat_list: ChatList::Main, + limit: 1000, + extra: Some(1), + }); + let chat = server.scenario.chats[0].clone(); + assert_ne!(chat.file_id, chat.replacement_file_id); + let events = server.apply_control(ControlCommand::ReplacePhoto { chat_id: chat.id }); + let ServerEvent::UpdateChatPhoto { chat_id, photo } = &events[0] else { + panic!("expected updateChatPhoto: {events:?}"); + }; + assert_eq!(*chat_id, chat.id); + let photo = photo.as_ref().expect("replacement photo"); + assert_eq!(photo.small.id, chat.replacement_file_id); + assert!(photo.small.is_downloading_completed); + assert_eq!( + photo.small.local_path, + chat.replacement.path.to_string_lossy() + ); + assert!(chat.replacement.exists()); + let _ = fs::remove_dir_all(&paths.root); + } + + #[test] + fn unsupported_requests_are_rejected_and_recorded_as_fixture_drift() { + let (mut server, paths) = server("unsupported", AvatarAvailability::ReadyOnDisk); + let events = server.handle(ServerRequest::Unsupported { + type_name: "sendMessage".to_string(), + extra: Some(12), + }); + assert_eq!( + events, + vec![ServerEvent::Error { + code: 400, + message: "NEOMACS_FIXTURE_UNSUPPORTED request sendMessage".to_string(), + extra: Some(12) + }] + ); + let log = read_log(&paths); + assert!( + log.iter().any(|line| line.record == "unsupported" + && line.type_name.as_deref() == Some("sendMessage")) + ); + let _ = fs::remove_dir_all(&paths.root); + } + + #[test] + fn control_reader_returns_only_new_commands_and_surfaces_invalid_lines() { + let paths = FixturePaths::new(temp_root("control")); + paths.create().expect("fixture paths"); + let mut reader = ControlReader::new(&paths.control); + assert!(reader.poll().expect("first poll").is_empty()); + fs::write( + &paths.control, + "{\"command\":\"deliver_file\",\"file_id\":5000}\n", + ) + .expect("append control"); + assert_eq!( + reader.poll().expect("second poll"), + vec![ControlCommand::DeliverFile { file_id: 5000 }] + ); + assert!(reader.poll().expect("third poll").is_empty()); + fs::write(&paths.control, "not json\n").expect("rewrite control"); + assert!(reader.poll().is_err()); + let _ = fs::remove_dir_all(&paths.root); + } + + #[test] + fn invoked_flags_match_telega_launch_and_answer_the_version_probe() { + assert!(matches!( + parse_invocation(&["-h".to_string()]), + Invocation::Probe(output) if output.starts_with("Version 1.0.0\n") + )); + let args = ["-v", "0", "-O", "127", "-l", "/tmp/log"].map(str::to_string); + assert!(matches!(parse_invocation(&args), Invocation::Serve)); + assert!(matches!( + parse_invocation(&["-z".to_string()]), + Invocation::Serve + )); + assert!(matches!( + parse_invocation(&["--telega".to_string()]), + Invocation::Error(_) + )); + } + + #[test] + fn generated_avatar_bytes_match_the_scenario_color() { + let bytes = super::super::scenario::encode_png(AVATAR_COLOR, 8); + let decoded = image::load_from_memory(&bytes).expect("decode").to_rgb8(); + assert_eq!(decoded.get_pixel(4, 4).0, AVATAR_COLOR); + } +} diff --git a/crates/neomacs-gui-tests/src/telega_fixture/mod.rs b/crates/neomacs-gui-tests/src/telega_fixture/mod.rs new file mode 100644 index 0000000000..0aa00aaa6f --- /dev/null +++ b/crates/neomacs-gui-tests/src/telega_fixture/mod.rs @@ -0,0 +1,18 @@ +//! Account-free Telega integration fixture. +//! +//! - [`protocol`] — Telega's byte-length framed plist wire protocol, typed +//! requests, typed events, and explicit rejection of unmodeled requests. +//! - [`scenario`] — deterministic synthetic chats and locally generated PNG +//! avatars (no personal or captured data). +//! - [`mock`] — the offline `telega-server` stand-in Telega launches through +//! its real `telega-server-command`, including the structured log and +//! control channel the GUI tests use as readiness checkpoints. +//! +//! The GUI integration tests live in `tests/telega_fixture_gui.rs` and the +//! editor-side scenario in `fixtures/telega-fixture-gui.el`; scope, isolation, +//! commands, and the separate retained-layout regression are documented in +//! `fixtures/telega-fixture.md`. This fixture exercises the Telega +//! frontend/process/rendering integration, not TDLib or MTProto. +pub mod mock; +pub mod protocol; +pub mod scenario; diff --git a/crates/neomacs-gui-tests/src/telega_fixture/protocol.rs b/crates/neomacs-gui-tests/src/telega_fixture/protocol.rs new file mode 100644 index 0000000000..7ec384fa00 --- /dev/null +++ b/crates/neomacs-gui-tests/src/telega_fixture/protocol.rs @@ -0,0 +1,1166 @@ +//! The `telega-server` stdin/stdout wire protocol. +//! +//! Telega's frontend talks to its server process through a byte-length framed +//! Lisp-plist protocol (`telega-server.el`): +//! +//! ```text +//! Emacs -> server: send \n\n +//! server -> Emacs: event \n\n +//! ``` +//! +//! `N` counts **bytes**, not characters: `telega-server--send` writes +//! `(string-bytes value)`. Requests carry `:@extra`, and Telega's process +//! filter matches a reply to its callback by that value, so every reply this +//! module emits preserves the request's `:@extra`. +//! +//! This module is the fixture's half of the agreed seam. It parses the +//! requests Telega actually sends into [`ServerRequest`] variants (with an +//! explicit [`ServerRequest::Unsupported`] for anything the fixture does not +//! model) and renders [`ServerEvent`] values back onto the wire. + +use std::fmt; +use std::io::{self, BufRead, Write}; + +/// Largest frame the fixture accepts. Real replies (chat batches, file +/// metadata) are far below this; the bound turns a desynchronized stream into +/// a clear error instead of an unbounded allocation. +pub const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024; + +/// A parsed client->server frame header. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ClientFrameKind { + Send, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ClientFrame { + pub kind: ClientFrameKind, + pub payload: LispValue, +} + +#[derive(Debug)] +pub enum ProtocolError { + Io(io::Error), + /// The stream carried a command this fixture does not implement. + UnknownCommand(String), + /// The declared byte length exceeded [`MAX_FRAME_BYTES`]. + FrameTooLarge(usize), + /// The payload was not a single well-formed Lisp object. + MalformedPayload(String), + /// The payload was not valid UTF-8. + NotUtf8(String), +} + +impl fmt::Display for ProtocolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(f, "telega-server protocol I/O error: {error}"), + Self::UnknownCommand(command) => { + write!(f, "unknown telega-server command `{command}`") + } + Self::FrameTooLarge(size) => { + write!( + f, + "telega-server frame of {size} bytes exceeds the fixture limit" + ) + } + Self::MalformedPayload(message) => { + write!(f, "malformed telega-server payload: {message}") + } + Self::NotUtf8(message) => write!(f, "telega-server payload is not UTF-8: {message}"), + } + } +} + +impl std::error::Error for ProtocolError {} + +impl From for ProtocolError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +/// The subset of Lisp values telega-server exchanges: plists of keywords, +/// strings, integers, symbols, `nil`/`t`, and nested lists/vectors. +#[derive(Clone, Debug, PartialEq)] +pub enum LispValue { + Nil, + True, + Integer(i64), + Float(f64), + String(String), + /// A bare symbol (`ok`) or a keyword (`:@type`, `:false`), stored verbatim. + Symbol(String), + List(Vec), + Vector(Vec), +} + +impl LispValue { + /// `plist-get`-style lookup for `:key` entries in a plist. + pub fn plist(&self, key: &str) -> Option<&LispValue> { + let items = match self { + Self::List(items) | Self::Vector(items) => items, + _ => return None, + }; + items + .chunks_exact(2) + .find_map(|pair| (pair[0] == Self::Symbol(key.to_string())).then_some(&pair[1])) + } + + pub fn as_str(&self) -> Option<&str> { + match self { + Self::String(value) => Some(value), + _ => None, + } + } + + pub fn as_i64(&self) -> Option { + match self { + Self::Integer(value) => Some(*value), + _ => None, + } + } + + pub fn as_bool(&self) -> Option { + match self { + Self::True => Some(true), + Self::Nil => Some(false), + _ => None, + } + } + + /// `:@type` as a plain Rust string, when present and a string. + pub fn tl_type(&self) -> Option<&str> { + self.plist(":@type").and_then(Self::as_str) + } + + pub fn extra(&self) -> Option { + self.plist(":@extra").and_then(Self::as_i64) + } + + pub fn is_nil(&self) -> bool { + matches!(self, Self::Nil) + } +} + +/// A typed view of the requests the fixture supports. +#[derive(Clone, Debug, PartialEq)] +pub enum ServerRequest { + SetOption { + name: String, + extra: Option, + }, + SetTdlibParameters { + database_directory: String, + files_directory: String, + extra: Option, + }, + SetNetworkType { + extra: Option, + }, + SetScopeNotificationSettings { + extra: Option, + }, + GetOption { + name: String, + extra: Option, + }, + LoadChats { + chat_list: ChatList, + limit: i64, + extra: Option, + }, + DownloadFile { + file_id: i64, + extra: Option, + }, + GetBlockedMessageSenders { + extra: Option, + }, + GetSavedMessagesTags { + extra: Option, + }, + GetBasicGroup { + basic_group_id: i64, + extra: Option, + }, + /// A syntactically valid request the fixture does not model. The mock + /// rejects it explicitly instead of pretending success. + Unsupported { + type_name: String, + extra: Option, + }, +} + +impl ServerRequest { + pub fn extra(&self) -> Option { + match self { + Self::SetOption { extra, .. } + | Self::SetTdlibParameters { extra, .. } + | Self::SetNetworkType { extra } + | Self::SetScopeNotificationSettings { extra } + | Self::GetOption { extra, .. } + | Self::LoadChats { extra, .. } + | Self::DownloadFile { extra, .. } + | Self::GetBlockedMessageSenders { extra } + | Self::GetSavedMessagesTags { extra } + | Self::GetBasicGroup { extra, .. } + | Self::Unsupported { extra, .. } => *extra, + } + } + + pub fn type_name(&self) -> &str { + match self { + Self::SetOption { .. } => "setOption", + Self::SetTdlibParameters { .. } => "setTdlibParameters", + Self::SetNetworkType { .. } => "setNetworkType", + Self::SetScopeNotificationSettings { .. } => "setScopeNotificationSettings", + Self::GetOption { .. } => "getOption", + Self::LoadChats { .. } => "loadChats", + Self::DownloadFile { .. } => "downloadFile", + Self::GetBlockedMessageSenders { .. } => "getBlockedMessageSenders", + Self::GetSavedMessagesTags { .. } => "getSavedMessagesTags", + Self::GetBasicGroup { .. } => "getBasicGroup", + Self::Unsupported { type_name, .. } => type_name, + } + } +} + +/// TDLib's two top-level chat lists, as Telega addresses them. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ChatList { + Main, + Archive, +} + +impl ChatList { + pub fn tl_type(self) -> &'static str { + match self { + Self::Main => "chatListMain", + Self::Archive => "chatListArchive", + } + } + + fn from_tl_type(name: &str) -> Option { + match name { + "chatListMain" => Some(Self::Main), + "chatListArchive" => Some(Self::Archive), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AuthorizationState { + WaitTdlibParameters, + Ready, +} + +impl AuthorizationState { + pub fn tl_type(self) -> &'static str { + match self { + Self::WaitTdlibParameters => "authorizationStateWaitTdlibParameters", + Self::Ready => "authorizationStateReady", + } + } +} + +/// A TDLib `file`, restricted to what the fixture needs to drive Telega's +/// download pipeline. +#[derive(Clone, Debug, PartialEq)] +pub struct FileObject { + pub id: i64, + pub size: i64, + pub local_path: String, + pub local_can_be_downloaded: bool, + pub is_downloading_active: bool, + pub is_downloading_completed: bool, +} + +impl FileObject { + pub fn to_lisp(&self) -> LispValue { + Plist::new("file") + .field(":id", LispValue::Integer(self.id)) + .field(":size", LispValue::Integer(self.size)) + .field(":expected_size", LispValue::Integer(self.size)) + .field( + ":local", + Plist::new("localFile") + .field(":path", LispValue::String(self.local_path.clone())) + .field( + ":can_be_downloaded", + bool_value(self.local_can_be_downloaded), + ) + .field( + ":is_downloading_active", + bool_value(self.is_downloading_active), + ) + .field( + ":is_downloading_completed", + bool_value(self.is_downloading_completed), + ) + .build(), + ) + .field( + ":remote", + Plist::new("remoteFile") + .field(":id", LispValue::String(format!("remote-{}", self.id))) + .field( + ":unique_id", + LispValue::String(format!("unique-{}", self.id)), + ) + .field(":is_uploading_active", LispValue::Nil) + .field(":is_uploading_completed", LispValue::True) + .build(), + ) + .build() + } +} + +/// Builds a flat Lisp plist (`(:key value :key value ...)`) — the shape the +/// C `telega-server` prints and `plist-get` consumes. +pub struct Plist { + items: Vec, +} + +impl Plist { + pub fn new(type_name: &str) -> Self { + Self { + items: vec![ + LispValue::Symbol(":@type".to_string()), + LispValue::String(type_name.to_string()), + ], + } + } + + pub fn field(mut self, key: &str, value: LispValue) -> Self { + self.items.push(LispValue::Symbol(key.to_string())); + self.items.push(value); + self + } + + pub fn optional_extra(mut self, extra: Option) -> Self { + if let Some(extra) = extra { + self.items.push(LispValue::Symbol(":@extra".to_string())); + self.items.push(LispValue::Integer(extra)); + } + self + } + + pub fn build(self) -> LispValue { + LispValue::List(self.items) + } +} + +fn bool_value(value: bool) -> LispValue { + if value { + LispValue::True + } else { + LispValue::Nil + } +} + +/// A typed `chat` object for `updateNewChat`/`getChat`. +#[derive(Clone, Debug, PartialEq)] +pub struct ChatObject { + pub id: i64, + pub title: String, + pub basic_group_id: i64, + pub photo: Option, + pub positions: Vec<(ChatList, i64)>, +} + +impl ChatObject { + pub fn to_lisp(&self) -> LispValue { + Plist::new("chat") + .field(":id", LispValue::Integer(self.id)) + .field(":title", LispValue::String(self.title.clone())) + .field( + ":type", + Plist::new("chatTypeBasicGroup") + .field(":basic_group_id", LispValue::Integer(self.basic_group_id)) + .build(), + ) + .field( + ":photo", + match &self.photo { + Some(photo) => photo.to_lisp(), + None => LispValue::Nil, + }, + ) + .field(":permissions", Plist::new("chatPermissions").build()) + .field(":unread_count", LispValue::Integer(0)) + .field( + ":positions", + LispValue::Vector( + self.positions + .iter() + .map(|(list, order)| { + Plist::new("chatPosition") + .field(":list", Plist::new(list.tl_type()).build()) + .field(":order", LispValue::String(order.to_string())) + .field(":is_pinned", LispValue::Nil) + .build() + }) + .collect(), + ), + ) + .field(":last_message", LispValue::Nil) + .field(":has_protected_content", LispValue::Nil) + .build() + } +} + +/// A typed `chatPhoto` with its two photo sizes. +#[derive(Clone, Debug, PartialEq)] +pub struct PhotoObject { + pub small: FileObject, + pub large: FileObject, +} + +impl PhotoObject { + pub fn to_lisp(&self) -> LispValue { + Plist::new("chatPhoto") + .field(":small", self.small.to_lisp()) + .field(":big", self.large.to_lisp()) + .build() + } +} + +/// Events the fixture sends to Telega. Every variant carries the `:@extra` +/// of the request it answers (or `None` for unsolicited updates). +#[derive(Clone, Debug, PartialEq)] +pub enum ServerEvent { + /// Unsolicited connection state; TDLib sends these without a request. + UpdateAuthorizationState { + state: AuthorizationState, + }, + /// The TDLib `optionValueInteger` result of `getOption` (int64 rendered + /// as a string, exactly like TDLib's JSON). + GetOptionInteger { + name: String, + value: i64, + extra: Option, + }, + /// `ok` — the generic successful reply to a TDLib call. + Ok { + extra: Option, + }, + Error { + code: i64, + message: String, + extra: Option, + }, + UpdateFile { + file: FileObject, + }, + BlockedMessageSenders { + extra: Option, + }, + SavedMessagesTags { + extra: Option, + }, + /// A full `chat` object (`updateNewChat`). + UpdateNewChat { + chat: ChatObject, + }, + /// One entry of a chat's `:positions` (`updateChatPosition`). + ChatPosition { + chat_id: i64, + list: ChatList, + order: i64, + }, + /// The chat's photo changed (`updateChatPhoto`); `None` removes it. + UpdateChatPhoto { + chat_id: i64, + photo: Option, + }, + /// A `file` object reply to `downloadFile`. + File { + file: FileObject, + extra: Option, + }, + /// A `basicGroup` object reply to `getBasicGroup`. + BasicGroup { + group: BasicGroupObject, + extra: Option, + }, +} + +/// A typed `basicGroup` TDLib object. +#[derive(Clone, Debug, PartialEq)] +pub struct BasicGroupObject { + pub id: i64, + pub member_count: i64, + pub is_active: bool, + /// `true` when the fixture's own account is the group creator. + pub is_creator: bool, +} + +impl BasicGroupObject { + pub fn to_lisp(&self) -> LispValue { + Plist::new("basicGroup") + .field(":id", LispValue::Integer(self.id)) + .field(":member_count", LispValue::Integer(self.member_count)) + .field( + ":status", + Plist::new(if self.is_creator { + "chatMemberStatusCreator" + } else { + "chatMemberStatusMember" + }) + .build(), + ) + .field(":is_active", bool_value(self.is_active)) + .field(":upgraded_to_supergroup_id", LispValue::Integer(0)) + .field(":updated_version", LispValue::String("1".to_string())) + .build() + } +} + +impl ServerEvent { + pub fn to_lisp(&self) -> LispValue { + match self { + Self::UpdateAuthorizationState { state } => Plist::new("updateAuthorizationState") + .field(":authorization_state", Plist::new(state.tl_type()).build()) + .build(), + // TDLib serializes int64 option values as strings; the idle-time + // callback reads `:value' through `string-to-number', and the + // reply is a direct `getOption' result (no `:name' field). + Self::GetOptionInteger { + name: _, + value, + extra, + } => Plist::new("optionValueInteger") + .field(":value", LispValue::String(value.to_string())) + .optional_extra(*extra) + .build(), + Self::Ok { extra } => Plist::new("ok").optional_extra(*extra).build(), + Self::Error { + code, + message, + extra, + } => Plist::new("error") + .field(":code", LispValue::Integer(*code)) + .field(":message", LispValue::String(message.clone())) + .optional_extra(*extra) + .build(), + Self::UpdateFile { file } => Plist::new("updateFile") + .field(":file", file.to_lisp()) + .build(), + Self::BlockedMessageSenders { extra } => Plist::new("messageSenders") + .field(":total_count", LispValue::Integer(0)) + .field(":senders", LispValue::Vector(Vec::new())) + .optional_extra(*extra) + .build(), + Self::SavedMessagesTags { extra } => Plist::new("savedMessagesTags") + .field(":tags", LispValue::Vector(Vec::new())) + .optional_extra(*extra) + .build(), + Self::UpdateNewChat { chat } => Plist::new("updateNewChat") + .field(":chat", chat.to_lisp()) + .build(), + Self::ChatPosition { + chat_id, + list, + order, + } => Plist::new("updateChatPosition") + .field(":chat_id", LispValue::Integer(*chat_id)) + .field( + ":position", + Plist::new("chatPosition") + .field(":list", Plist::new(list.tl_type()).build()) + .field(":order", LispValue::String(order.to_string())) + .field(":is_pinned", LispValue::Nil) + .build(), + ) + .build(), + Self::UpdateChatPhoto { chat_id, photo } => Plist::new("updateChatPhoto") + .field(":chat_id", LispValue::Integer(*chat_id)) + .field( + ":photo", + match photo { + Some(photo) => photo.to_lisp(), + None => LispValue::Nil, + }, + ) + .build(), + Self::File { file, extra } => { + let mut items = file.to_lisp(); + if let LispValue::List(items) = &mut items + && let Some(extra) = extra + { + items.push(LispValue::Symbol(":@extra".to_string())); + items.push(LispValue::Integer(*extra)); + } + items + } + Self::BasicGroup { group, extra } => { + let mut items = group.to_lisp(); + if let LispValue::List(items) = &mut items + && let Some(extra) = extra + { + items.push(LispValue::Symbol(":@extra".to_string())); + items.push(LispValue::Integer(*extra)); + } + items + } + } + } +} + +/// Read one framed request from Telega. +/// +/// Returns `Ok(None)` at a clean end of stream (Telega closed the pipe). +pub fn read_client_frame(reader: &mut impl BufRead) -> Result, ProtocolError> { + let mut header = String::new(); + let read = reader.read_line(&mut header)?; + if read == 0 { + return Ok(None); + } + let header = header.trim_end_matches(['\n', '\r']); + let (command, size) = header + .split_once(' ') + .ok_or_else(|| ProtocolError::MalformedPayload(format!("invalid header `{header}`")))?; + let size: usize = size + .parse() + .map_err(|_| ProtocolError::MalformedPayload(format!("invalid frame size `{size}`")))?; + if size > MAX_FRAME_BYTES { + return Err(ProtocolError::FrameTooLarge(size)); + } + let kind = match command { + "send" => ClientFrameKind::Send, + other => return Err(ProtocolError::UnknownCommand(other.to_string())), + }; + let mut bytes = vec![0_u8; size]; + reader.read_exact(&mut bytes)?; + let mut newline = [0_u8; 1]; + reader.read_exact(&mut newline)?; + if newline[0] != b'\n' { + return Err(ProtocolError::MalformedPayload( + "frame payload is not terminated by a newline".to_string(), + )); + } + let text = + String::from_utf8(bytes).map_err(|error| ProtocolError::NotUtf8(error.to_string()))?; + let payload = parse_expr(&text)?; + Ok(Some(ClientFrame { kind, payload })) +} + +/// Write one server event as a `event \n\n` frame. +pub fn write_server_event( + writer: &mut impl Write, + event: &ServerEvent, +) -> Result<(), ProtocolError> { + write_server_object(writer, &event.to_lisp()) +} + +/// Write a raw Lisp object as a server frame (test/helper seam). +pub fn write_server_object( + writer: &mut impl Write, + object: &LispValue, +) -> Result<(), ProtocolError> { + let text = print_expr(object); + write!(writer, "event {}\n{}\n", text.len(), text)?; + writer.flush()?; + Ok(()) +} + +/// Parse one Lisp expression (the payload text, without the frame header). +pub fn parse_expr(text: &str) -> Result { + let mut parser = Parser { + bytes: text.as_bytes(), + position: 0, + }; + let value = parser.parse_value()?; + parser.skip_whitespace(); + if parser.position != parser.bytes.len() { + return Err(ProtocolError::MalformedPayload(format!( + "trailing input after expression at byte {}", + parser.position + ))); + } + Ok(value) +} + +/// Render one Lisp expression the way the C `telega-server` prints plists. +pub fn print_expr(value: &LispValue) -> String { + let mut output = String::new(); + print_into(value, &mut output); + output +} + +fn print_into(value: &LispValue, output: &mut String) { + match value { + LispValue::Nil => output.push_str("nil"), + LispValue::True => output.push('t'), + LispValue::Integer(number) => output.push_str(&number.to_string()), + LispValue::Float(number) => output.push_str(&format!("{number}")), + LispValue::Symbol(symbol) => output.push_str(symbol), + LispValue::String(text) => { + output.push('"'); + for character in text.chars() { + match character { + '"' => output.push_str("\\\""), + '\\' => output.push_str("\\\\"), + '\n' => output.push_str("\\n"), + '\t' => output.push_str("\\t"), + '\r' => output.push_str("\\r"), + other => output.push(other), + } + } + output.push('"'); + } + LispValue::List(items) => { + output.push('('); + for (index, item) in items.iter().enumerate() { + if index > 0 { + output.push(' '); + } + print_into(item, output); + } + output.push(')'); + } + LispValue::Vector(items) => { + output.push('['); + for (index, item) in items.iter().enumerate() { + if index > 0 { + output.push(' '); + } + print_into(item, output); + } + output.push(']'); + } + } +} + +struct Parser<'a> { + bytes: &'a [u8], + position: usize, +} + +impl<'a> Parser<'a> { + fn skip_whitespace(&mut self) { + while self + .bytes + .get(self.position) + .is_some_and(u8::is_ascii_whitespace) + { + self.position += 1; + } + } + + fn peek(&self) -> Option { + self.bytes.get(self.position).copied() + } + + fn parse_value(&mut self) -> Result { + self.skip_whitespace(); + match self.peek() { + None => Err(ProtocolError::MalformedPayload( + "unexpected end of payload".to_string(), + )), + Some(b'(') | Some(b'[') => self.parse_sequence(), + Some(b'"') => self.parse_string(), + Some(_) => self.parse_atom(), + } + } + + fn parse_sequence(&mut self) -> Result { + let open = self.peek().expect("caller checked the open delimiter"); + let close = if open == b'(' { b')' } else { b']' }; + self.position += 1; + let mut items = Vec::new(); + loop { + self.skip_whitespace(); + match self.peek() { + Some(byte) if byte == close => { + self.position += 1; + return Ok(if open == b'(' { + LispValue::List(items) + } else { + LispValue::Vector(items) + }); + } + None => { + return Err(ProtocolError::MalformedPayload( + "unterminated sequence".to_string(), + )); + } + Some(_) => items.push(self.parse_value()?), + } + } + } + + fn parse_string(&mut self) -> Result { + self.position += 1; // opening quote + let mut text = String::new(); + loop { + let byte = self.peek().ok_or_else(|| { + ProtocolError::MalformedPayload("unterminated string".to_string()) + })?; + self.position += 1; + match byte { + b'"' => return Ok(LispValue::String(text)), + b'\\' => { + let escaped = self.peek().ok_or_else(|| { + ProtocolError::MalformedPayload("dangling string escape".to_string()) + })?; + self.position += 1; + match escaped { + b'"' => text.push('"'), + b'\\' => text.push('\\'), + b'n' => text.push('\n'), + b't' => text.push('\t'), + b'r' => text.push('\r'), + other => { + return Err(ProtocolError::MalformedPayload(format!( + "unsupported string escape \\\\{}", + other as char + ))); + } + } + } + _ => { + // Copy the full UTF-8 sequence for this byte. + let start = self.position - 1; + let width = utf8_width(byte); + let end = start + width; + let slice = self.bytes.get(start..end).ok_or_else(|| { + ProtocolError::MalformedPayload("truncated UTF-8 string".to_string()) + })?; + let character = std::str::from_utf8(slice) + .map_err(|error| ProtocolError::NotUtf8(error.to_string()))?; + text.push_str(character); + self.position = end; + } + } + } + } + + fn parse_atom(&mut self) -> Result { + let start = self.position; + while self.peek().is_some_and(|byte| { + !byte.is_ascii_whitespace() && !matches!(byte, b'(' | b')' | b'[' | b']' | b'"') + }) { + self.position += 1; + } + let atom = std::str::from_utf8(&self.bytes[start..self.position]) + .map_err(|error| ProtocolError::NotUtf8(error.to_string()))?; + if atom.is_empty() { + return Err(ProtocolError::MalformedPayload( + "empty atom in payload".to_string(), + )); + } + if atom == "nil" { + return Ok(LispValue::Nil); + } + if atom == "t" { + return Ok(LispValue::True); + } + if let Ok(integer) = atom.parse::() { + return Ok(LispValue::Integer(integer)); + } + if let Ok(float) = atom.parse::() + && (atom.contains('.') || atom.contains('e') || atom.contains('E')) + { + return Ok(LispValue::Float(float)); + } + Ok(LispValue::Symbol(atom.to_string())) + } +} + +fn utf8_width(first: u8) -> usize { + match first { + 0x00..=0x7f => 1, + 0xc0..=0xdf => 2, + 0xe0..=0xef => 3, + _ => 4, + } +} + +/// Decode a parsed payload into a typed request. +pub fn decode_request(payload: &LispValue) -> Result { + let extra = payload.extra(); + let type_name = payload.tl_type().ok_or_else(|| { + ProtocolError::MalformedPayload("request has no :@type string".to_string()) + })?; + let string_field = |key: &str| -> Result { + payload + .plist(key) + .and_then(LispValue::as_str) + .map(str::to_string) + .ok_or_else(|| { + ProtocolError::MalformedPayload(format!("request {type_name} lacks {key}")) + }) + }; + let integer_field = |key: &str| -> Result { + payload + .plist(key) + .and_then(LispValue::as_i64) + .ok_or_else(|| { + ProtocolError::MalformedPayload(format!("request {type_name} lacks {key}")) + }) + }; + let chat_list = || -> Result { + payload + .plist(":chat_list") + .and_then(LispValue::tl_type) + .and_then(ChatList::from_tl_type) + .ok_or_else(|| { + ProtocolError::MalformedPayload(format!( + "request {type_name} has an unsupported :chat_list" + )) + }) + }; + Ok(match type_name { + "setOption" => ServerRequest::SetOption { + name: string_field(":name")?, + extra, + }, + "setTdlibParameters" => ServerRequest::SetTdlibParameters { + database_directory: string_field(":database_directory")?, + files_directory: string_field(":files_directory")?, + extra, + }, + "setNetworkType" => ServerRequest::SetNetworkType { extra }, + "setScopeNotificationSettings" => ServerRequest::SetScopeNotificationSettings { extra }, + "getOption" => ServerRequest::GetOption { + name: string_field(":name")?, + extra, + }, + "loadChats" => ServerRequest::LoadChats { + chat_list: chat_list()?, + limit: integer_field(":limit").unwrap_or(0), + extra, + }, + "downloadFile" => ServerRequest::DownloadFile { + file_id: integer_field(":file_id")?, + extra, + }, + "getBlockedMessageSenders" => ServerRequest::GetBlockedMessageSenders { extra }, + "getSavedMessagesTags" => ServerRequest::GetSavedMessagesTags { extra }, + "getBasicGroup" => ServerRequest::GetBasicGroup { + basic_group_id: integer_field(":basic_group_id")?, + extra, + }, + other => ServerRequest::Unsupported { + type_name: other.to_string(), + extra, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + fn framed(payload: &str) -> Vec { + format!("send {}\n{}\n", payload.len(), payload).into_bytes() + } + + #[test] + fn reads_frames_by_utf8_byte_length_and_stays_in_sync() { + // The Cyrillic title makes char count < byte count, so a reader that + // framed by characters would desynchronize on the second frame. + let first = r#"(:@type "getOption" :name "language_pack_id" :title "Привет" :@extra 7)"#; + let second = + r#"(:@type "loadChats" :chat_list (:@type "chatListMain") :limit 1000 :@extra 8)"#; + let mut stream = framed(first); + stream.extend(framed(second)); + assert_ne!( + first.chars().count(), + first.len(), + "test payload must contain multibyte characters" + ); + + let mut reader = Cursor::new(stream); + let frame = read_client_frame(&mut reader) + .expect("first frame parses") + .expect("stream has a first frame"); + assert_eq!(frame.kind, ClientFrameKind::Send); + assert_eq!(frame.payload.tl_type(), Some("getOption")); + assert_eq!( + frame.payload.plist(":title").and_then(LispValue::as_str), + Some("Привет") + ); + assert_eq!(frame.payload.extra(), Some(7)); + + let frame = read_client_frame(&mut reader) + .expect("second frame parses") + .expect("stream has a second frame"); + assert_eq!(frame.payload.tl_type(), Some("loadChats")); + assert_eq!(frame.payload.extra(), Some(8)); + assert!(read_client_frame(&mut reader).expect("clean end").is_none()); + } + + #[test] + fn round_trips_an_event_frame_with_multibyte_paths() { + let event = ServerEvent::UpdateFile { + file: FileObject { + id: 42, + size: 512, + local_path: "/tmp/фикстура/аватар-42.png".to_string(), + local_can_be_downloaded: false, + is_downloading_active: false, + is_downloading_completed: true, + }, + }; + let mut bytes = Vec::new(); + write_server_event(&mut bytes, &event).expect("frame writes"); + let text = String::from_utf8(bytes).expect("frame is UTF-8"); + let (header, payload) = text.split_once('\n').expect("header line"); + let declared: usize = header + .strip_prefix("event ") + .expect("event header") + .parse() + .expect("byte length"); + let payload = payload.strip_suffix('\n').expect("trailing newline"); + assert_eq!(declared, payload.len()); + assert_ne!(declared, payload.chars().count()); + + let parsed = parse_expr(payload).expect("payload parses"); + assert_eq!(parsed.tl_type(), Some("updateFile")); + let local = parsed + .plist(":file") + .and_then(|file| file.plist(":local")) + .expect("file has :local"); + assert_eq!( + local.plist(":path").and_then(LispValue::as_str), + Some("/tmp/фикстура/аватар-42.png") + ); + assert_eq!( + local + .plist(":is_downloading_completed") + .and_then(LispValue::as_bool), + Some(true) + ); + } + + #[test] + fn decodes_supported_requests_and_rejects_unknown_ones_explicitly() { + let cases = [ + ( + r#"(:@type "setOption" :name "online" :value t :@extra 1)"#, + ServerRequest::SetOption { + name: "online".to_string(), + extra: Some(1), + }, + ), + ( + r#"(:@type "loadChats" :chat_list (:@type "chatListMain") :limit 1000 :@extra 2)"#, + ServerRequest::LoadChats { + chat_list: ChatList::Main, + limit: 1000, + extra: Some(2), + }, + ), + ( + r#"(:@type "downloadFile" :file_id 9 :priority 1 :offset 0 :limit 0 :synchronous t :@extra 3)"#, + ServerRequest::DownloadFile { + file_id: 9, + extra: Some(3), + }, + ), + ( + r#"(:@type "getOption" :name "unix_time" :@extra 4)"#, + ServerRequest::GetOption { + name: "unix_time".to_string(), + extra: Some(4), + }, + ), + ]; + for (text, expected) in cases { + let payload = parse_expr(text).expect("payload parses"); + assert_eq!(decode_request(&payload).expect("decodes"), expected); + } + + let unknown = + parse_expr(r#"(:@type "sendMessage" :chat_id 1 :@extra 11)"#).expect("payload parses"); + assert_eq!( + decode_request(&unknown).expect("decodes as unsupported"), + ServerRequest::Unsupported { + type_name: "sendMessage".to_string(), + extra: Some(11), + } + ); + } + + #[test] + fn error_replies_keep_the_correlation_extra() { + let event = ServerEvent::Error { + code: 404, + message: "Chats not found".to_string(), + extra: Some(35), + }; + let wire = print_expr(&event.to_lisp()); + assert!( + wire.starts_with(r#"(:@type "error" :code 404 :message "Chats not found""#), + "{wire}" + ); + assert!(wire.ends_with(":@extra 35)"), "{wire}"); + // The wire form is a plist the fixture can read back, not a list of + // key/value pairs. + let parsed = parse_expr(&wire).expect("printed event parses"); + assert_eq!(parsed.tl_type(), Some("error")); + assert_eq!(parsed.extra(), Some(35)); + } + + #[test] + fn unix_time_reply_is_a_correlated_tdlib_option_value_integer() { + // TDLib answers `getOption' with `optionValueInteger' whose int64 + // `:value' is a *string*; Telega's idle callback reads it through + // `string-to-number'. This is not an `updateOption' event. + let event = ServerEvent::GetOptionInteger { + name: "unix_time".to_string(), + value: 1_700_000_000, + extra: Some(21), + }; + let wire = print_expr(&event.to_lisp()); + assert_eq!( + wire, + r#"(:@type "optionValueInteger" :value "1700000000" :@extra 21)"# + ); + let parsed = parse_expr(&wire).expect("option reply parses"); + assert_eq!(parsed.tl_type(), Some("optionValueInteger")); + assert_eq!( + parsed.plist(":value").and_then(LispValue::as_str), + Some("1700000000") + ); + assert_eq!(parsed.extra(), Some(21)); + assert!( + parsed.plist(":name").is_none(), + "a getOption result carries only :value: {wire}" + ); + } + + #[test] + fn unsupported_and_malformed_payloads_are_rejected_with_context() { + assert!(matches!( + parse_expr("(:\"unterminated"), + Err(ProtocolError::MalformedPayload(_)) + )); + assert!(matches!( + parse_expr("(:@type \"ok\") trailing"), + Err(ProtocolError::MalformedPayload(_)) + )); + assert!(matches!( + parse_expr("(:@type \"ok\" \"odd\""), + Err(ProtocolError::MalformedPayload(_)) + )); + // A non-UTF-8 byte sequence in a string is a clear error, not a panic. + let mut payload = b"(:name \"".to_vec(); + payload.extend_from_slice(&[0xff, 0xfe, 0xfd]); + payload.push(b'"'); + payload.push(b')'); + let mut bytes = format!("send {}\n", payload.len()).into_bytes(); + bytes.extend_from_slice(&payload); + bytes.push(b'\n'); + let mut reader = Cursor::new(bytes); + assert!(matches!( + read_client_frame(&mut reader), + Err(ProtocolError::NotUtf8(_)) + )); + } +} diff --git a/crates/neomacs-gui-tests/src/telega_fixture/scenario.rs b/crates/neomacs-gui-tests/src/telega_fixture/scenario.rs new file mode 100644 index 0000000000..bf2ee0bec7 --- /dev/null +++ b/crates/neomacs-gui-tests/src/telega_fixture/scenario.rs @@ -0,0 +1,362 @@ +//! Deterministic synthetic scenario for the account-free Telega fixture. +//! +//! Everything here is generated: chat ids, titles, photo file ids, and the +//! avatar pixels. No real Telegram account, database, cache, profile photo, +//! or conversation is read or reproduced. Avatars are written as lossless +//! PNGs whose colors are chosen to be far from every color Telega's own +//! builtin palettes can draw, so a screenshot can assert their presence and +//! absence by pixel color. + +use std::fs; +use std::io; +use std::path::PathBuf; + +/// Number of synthetic chats. Each chat occupies one root-buffer line, so +/// this exceeds one ~34-row viewport several times over and forces real +/// paging through at least three viewports. +pub const CHAT_COUNT: usize = 112; + +/// Base chat-list position order. TDLib `chatPosition.order` values are +/// positive int64s; Telega keeps them as strings, so every chat must use the +/// same digit width or the lexicographic sort would disagree with the numeric +/// one ("9000" > "40000"). Descending from this base puts "Synthetic Group +/// 01" first through "Synthetic Group 112" last. +pub const CHAT_ORDER_BASE: i64 = 9_000_000_000_000_000_000; + +/// Avatar edge length in pixels. Large enough that the rendered circle has +/// many interior pixels of the exact source color. +pub const AVATAR_PIXELS: u32 = 256; + +/// Color used by every already-available synthetic avatar. Distinct from +/// `REPLACEMENT_COLOR` and from `telega-builtin-palettes-alist` entries +/// (checked by the palette-distance unit test below). +pub const AVATAR_COLOR: [u8; 3] = [255, 0, 255]; + +/// Color of the replacement photo delivered by `updateChatPhoto`. +pub const REPLACEMENT_COLOR: [u8; 3] = [0, 255, 127]; + +/// The first chat's avatar gets its own color, so a pixel test can prove that +/// *that* chat's photo was replaced instead of a new one having been added. +pub const REPLACED_CHAT_AVATAR_COLOR: [u8; 3] = [255, 128, 255]; + +/// How the fixture makes avatar files available. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AvatarAvailability { + /// `:local :path` already points at the file and + /// `:is_downloading_completed` is true: Telega never calls `downloadFile`. + ReadyOnDisk, + /// The file object starts without a local path; the fixture writes the + /// PNG when Telega calls `downloadFile`, and afterwards announces + /// `updateFile` (driven by an explicit control command) with the path and + /// `:is_downloading_completed` true. + DelayedUntilDownload, +} + +impl AvatarAvailability { + pub fn from_name(name: &str) -> Option { + match name { + "ready" => Some(Self::ReadyOnDisk), + "delayed" => Some(Self::DelayedUntilDownload), + _ => None, + } + } + + pub fn name(self) -> &'static str { + match self { + Self::ReadyOnDisk => "ready", + Self::DelayedUntilDownload => "delayed", + } + } +} + +/// A generated avatar: one solid color, written to one PNG file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AvatarImage { + pub path: PathBuf, + pub color: [u8; 3], +} + +impl AvatarImage { + pub fn write(&self) -> io::Result<()> { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&self.path, encode_png(self.color, AVATAR_PIXELS)) + } + + pub fn exists(&self) -> bool { + self.path.is_file() + } +} + +/// One synthetic group chat with a generated photo. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SyntheticChat { + pub id: i64, + pub title: String, + /// File id of the avatar in the chat's `chatPhoto`. + pub file_id: i64, + pub avatar: AvatarImage, + /// File id and image used by the replacement `updateChatPhoto` event. + pub replacement_file_id: i64, + pub replacement: AvatarImage, + /// Chat list position; higher sorts first, distinct per chat. All + /// values have the same digit width (see [`CHAT_ORDER_BASE`]). + pub order: i64, +} + +#[derive(Clone, Debug)] +pub struct FixtureScenario { + pub availability: AvatarAvailability, + pub photos_dir: PathBuf, + pub chats: Vec, +} + +impl FixtureScenario { + /// Build the deterministic scenario below `photos_dir`. + pub fn new(photos_dir: impl Into, availability: AvatarAvailability) -> Self { + let photos_dir = photos_dir.into(); + let chats = (0..CHAT_COUNT) + .map(|index| { + let id = 1_000 + index as i64; + let file_id = 5_000 + index as i64; + let replacement_file_id = 7_000 + index as i64; + let avatar_color = if index == 0 { + REPLACED_CHAT_AVATAR_COLOR + } else { + AVATAR_COLOR + }; + SyntheticChat { + id, + title: format!("Synthetic Group {:02}", index + 1), + file_id, + avatar: AvatarImage { + path: photos_dir.join(format!("avatar-{file_id}.png")), + color: avatar_color, + }, + replacement_file_id, + replacement: AvatarImage { + path: photos_dir.join(format!("avatar-{replacement_file_id}.png")), + color: REPLACEMENT_COLOR, + }, + // Telega sorts chats by descending position order. The + // base keeps every value the same width, so Telega's + // string comparison matches the numeric TDLib order. + order: CHAT_ORDER_BASE - index as i64, + } + }) + .collect(); + Self { + availability, + photos_dir, + chats, + } + } + + pub fn chat(&self, chat_id: i64) -> Option<&SyntheticChat> { + self.chats.iter().find(|chat| chat.id == chat_id) + } + + pub fn chat_for_file(&self, file_id: i64) -> Option<&SyntheticChat> { + self.chats + .iter() + .find(|chat| chat.file_id == file_id || chat.replacement_file_id == file_id) + } + + /// Write every avatar that must already exist for this availability mode. + pub fn materialize_ready_avatars(&self) -> io::Result<()> { + match self.availability { + // Delayed avatars are written when the fixture observes the + // download request. + AvatarAvailability::DelayedUntilDownload => Ok(()), + AvatarAvailability::ReadyOnDisk => { + for chat in &self.chats { + chat.avatar.write()?; + } + Ok(()) + } + } + } +} + +/// Encode one solid-color RGBA-free PNG deterministically. +pub fn encode_png(color: [u8; 3], size: u32) -> Vec { + use image::ImageEncoder; + let pixels = vec![color; (size * size) as usize]; + let mut bytes = Vec::new(); + let encoder = image::codecs::png::PngEncoder::new(&mut bytes); + encoder + .write_image( + pixels.concat().as_slice(), + size, + size, + image::ExtendedColorType::Rgb8, + ) + .expect("in-memory PNG encoding cannot fail"); + bytes +} + +/// The avatar colors Telega itself can draw for avatar backgrounds (dark and +/// light builtin palettes, plus the initials-circle gradient endpoints). +/// Asserted disjoint from the fixture's colors so a screenshot pixel test can +/// never be satisfied by Telega's own placeholder art. +pub const TELEGA_BUILTIN_PALETTE_COLORS: &[[u8; 3]] = &[ + [0xff, 0x0a, 0x0a], + [0xdd, 0x00, 0x00], + [0xff, 0x8d, 0x1e], + [0xf6, 0xc2, 0xf6], + [0x0a, 0xff, 0x0a], + [0x00, 0xdd, 0x00], + [0x00, 0xf5, 0xf6], + [0x3e, 0x9e, 0xfd], + [0xfa, 0xbd, 0xdd], + [0x99, 0x00, 0x00], + [0xaa, 0x00, 0x00], + [0x00, 0x70, 0x00], + [0x00, 0x33, 0x65], + [0x5e, 0x07, 0x36], +]; + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "neomacs-telega-scenario-{name}-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("create scenario temp dir"); + dir + } + + fn color_distance(left: [u8; 3], right: [u8; 3]) -> u32 { + left.iter() + .zip(right.iter()) + .map(|(a, b)| a.abs_diff(*b) as u32) + .sum() + } + + #[test] + fn scenario_is_deterministic_and_has_enough_chats_for_multiple_pages() { + let first = FixtureScenario::new("/tmp/photos-a", AvatarAvailability::ReadyOnDisk); + let second = FixtureScenario::new("/tmp/photos-b", AvatarAvailability::ReadyOnDisk); + assert_eq!(first.chats.len(), CHAT_COUNT); + assert!( + first.chats.len() >= 96, + "the scenario must span at least three root-buffer pages" + ); + for (left, right) in first.chats.iter().zip(second.chats.iter()) { + assert_eq!(left.id, right.id); + assert_eq!(left.title, right.title); + assert_eq!(left.file_id, right.file_id); + assert_eq!(left.order, right.order); + assert_eq!(left.avatar.color, right.avatar.color); + } + // Distinct ids and titles: no two "chats" are the same row. + let mut ids: Vec = first.chats.iter().map(|chat| chat.id).collect(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), CHAT_COUNT); + } + + #[test] + fn chat_order_is_descending_same_width_int64_so_group_01_leads() { + let scenario = FixtureScenario::new("/tmp/photos", AvatarAvailability::ReadyOnDisk); + assert_eq!( + scenario.chats.first().expect("first chat").title, + "Synthetic Group 01" + ); + assert_eq!( + scenario.chats.last().expect("last chat").title, + format!("Synthetic Group {CHAT_COUNT:02}") + ); + + let orders: Vec = scenario + .chats + .iter() + .map(|chat| chat.order.to_string()) + .collect(); + let width = orders[0].len(); + for order in &orders { + assert_eq!( + order.len(), + width, + "chat order strings must share one width or Telega's string sort \ + disagrees with the numeric order: {orders:?}" + ); + assert!( + !order.starts_with('-'), + "chat orders must be positive int64s: {order}" + ); + } + for pair in orders.windows(2) { + assert!( + pair[0] > pair[1], + "chat orders must descend so Group 01 sorts first: {pair:?}" + ); + } + // String order and numeric order agree by construction. + let mut lexicographic = orders.clone(); + lexicographic.sort(); + lexicographic.reverse(); + assert_eq!(lexicographic, orders); + } + + #[test] + fn generated_avatar_png_is_deterministic_and_lossless() { + let bytes = encode_png(AVATAR_COLOR, 32); + assert_eq!(bytes, encode_png(AVATAR_COLOR, 32)); + assert_ne!(bytes, encode_png(REPLACEMENT_COLOR, 32)); + + let decoded = image::load_from_memory(&bytes) + .expect("generated PNG decodes") + .to_rgb8(); + assert_eq!(decoded.dimensions(), (32, 32)); + assert_eq!(decoded.get_pixel(16, 16).0, AVATAR_COLOR); + } + + #[test] + fn fixture_colors_are_disjoint_from_telega_builtin_palette_colors() { + for fixture_color in [AVATAR_COLOR, REPLACEMENT_COLOR, REPLACED_CHAT_AVATAR_COLOR] { + for palette in TELEGA_BUILTIN_PALETTE_COLORS { + assert!( + color_distance(fixture_color, *palette) > 60, + "fixture color {fixture_color:?} is too close to Telega palette {palette:?}" + ); + } + } + assert!( + color_distance(AVATAR_COLOR, REPLACEMENT_COLOR) > 200, + "avatar and replacement colors must be unmistakably different" + ); + assert!( + color_distance(AVATAR_COLOR, REPLACED_CHAT_AVATAR_COLOR) > 60, + "the replaceable chat's avatar color must be countable separately" + ); + } + + #[test] + fn ready_scenario_writes_every_avatar_and_delayed_writes_none() { + let ready_dir = temp_dir("ready"); + let ready = FixtureScenario::new(&ready_dir, AvatarAvailability::ReadyOnDisk); + ready + .materialize_ready_avatars() + .expect("write ready avatars"); + assert!( + ready + .chats + .iter() + .all(|chat| chat.avatar.exists() && !chat.replacement.exists()) + ); + + let delayed_dir = temp_dir("delayed"); + let delayed = FixtureScenario::new(&delayed_dir, AvatarAvailability::DelayedUntilDownload); + delayed.materialize_ready_avatars().expect("no-op"); + assert!(delayed.chats.iter().all(|chat| !chat.avatar.exists())); + + let _ = fs::remove_dir_all(&ready_dir); + let _ = fs::remove_dir_all(&delayed_dir); + } +} diff --git a/crates/neomacs-gui-tests/tests/telega_fixture_gui.rs b/crates/neomacs-gui-tests/tests/telega_fixture_gui.rs new file mode 100644 index 0000000000..7c24ed3e8d --- /dev/null +++ b/crates/neomacs-gui-tests/tests/telega_fixture_gui.rs @@ -0,0 +1,1591 @@ +//! Account-free Telega frontend integration on a private display. +//! +//! These tests mount the real pinned Telega Lisp frontend (provisioned by +//! neomacs-infra), point its real `telega-server-command` at the offline +//! fixture mock, and assert on what the real root buffer/REDISPLAY produced: +//! rendered chat rows, per-row avatar pixels, and page-up/page-down viewport +//! motion. Telega's process filter, callback dispatch, image construction, +//! and scrolling are never mocked. +//! +//! Scope: this is frontend/process/rendering integration, not TDLib/MTProto +//! correctness. It proves nothing about the user's reported avatar +//! disappearance; it is determinism and rendering coverage for the frontend. +//! Delayed file delivery and photo replacement are checked as real frontend +//! events; see `fixtures/telega-fixture.md` for the coverage boundary. +//! +//! Isolation and cleanup: every test runs on a harness-owned Xvfb display +//! with a fresh HOME/XDG/TMPDIR tree below the fixture root, an owned +//! runtime directory published through `/proc//fd/`, no inherited +//! user display, Wayland socket, D-Bus session, `EMACSLOADPATH`, or Telegram +//! path. The editor is spawned in its own process group; teardown sends a +//! graceful quit and then signals exactly that recorded group, so the mock +//! and editor cannot leak (see `fixture_tears_down_its_owned_process_group`). + +#![cfg(target_os = "linux")] + +use neomacs_gui_tests::telega_fixture::mock::{ + ControlCommand, FIXTURE_ROOT_ENV, FIXTURE_SCENARIO_ENV, LogLine, +}; +use neomacs_gui_tests::telega_fixture::scenario::{ + AVATAR_COLOR, AvatarAvailability, CHAT_COUNT, REPLACED_CHAT_AVATAR_COLOR, REPLACEMENT_COLOR, +}; +use neomacs_gui_tests::{DisplayHarness, GuiBackend}; +use neomacs_infra::packages::{self, PathGnuDriver}; +use serde_json::Value; +use std::cell::Cell; +use std::fs; +use std::io::Write as _; +use std::os::fd::AsRawFd; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +/// Minimum interior pixels for one rendered 256px-source avatar (the visible +/// circle is ~13px across, so ~130 interior pixels survive clipping). +const ONE_AVATAR_PIXELS: usize = 50; +/// Horizontal band at the line beginning that owns the avatar image. +const AVATAR_BAND_WIDTH: f32 = 48.0; + +fn chat_title(index: usize) -> String { + format!("Synthetic Group {:02}", index + 1) +} + +/// The color every ready avatar renders with; chat 01 is distinct so a +/// replacement can be attributed to that exact row. +fn expected_ready_color(index: usize) -> [u8; 3] { + if index == 0 { + REPLACED_CHAT_AVATAR_COLOR + } else { + AVATAR_COLOR + } +} + +#[test] +fn mock_answers_the_telega_server_version_probe() { + let output = Command::new(mock_binary()) + .arg("-h") + .output() + .expect("run the fixture mock version probe"); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.starts_with("Version "), "{stdout}"); + let version = stdout + .lines() + .next() + .and_then(|line| line.strip_prefix("Version ")) + .and_then(|rest| rest.split_whitespace().next()) + .expect("version number"); + let (major, minor, _patch) = parse_version(version); + assert!( + (major, minor) >= (0, 7), + "fixture mock must satisfy telega-server-min-version 0.7.7, got {version}" + ); +} + +fn parse_version(version: &str) -> (u32, u32, u32) { + let mut parts = version.split('.').map(|part| part.parse().unwrap_or(0)); + ( + parts.next().unwrap_or(0), + parts.next().unwrap_or(0), + parts.next().unwrap_or(0), + ) +} + +#[test] +fn telega_frontend_renders_synthetic_chats_and_paged_avatars_on_a_private_display() { + let fixture = Fixture::start(AvatarAvailability::ReadyOnDisk, "render-paging"); + let checkpoint = fixture.wait_for_ready(); + assert_eq!(checkpoint["chats"].as_u64(), Some(CHAT_COUNT as u64)); + assert_eq!(checkpoint["auth"].as_str(), Some("\"Ready\"")); + assert_eq!( + checkpoint["selected-buffer"].as_str(), + Some("*Telega Root*"), + "the fixture must show the real root buffer: {checkpoint}" + ); + fixture.assert_isolation(); + fixture.assert_no_fixture_drift(); + + // --- Page 1: every fully visible synthetic row owns its avatar. --- + let initial_start = fixture.window_start(); + assert_eq!(initial_start, 1, "the root buffer must start at the top"); + let (top_snapshot, top_png, top_start) = fixture.corresponding_state("page-top"); + assert_eq!( + top_start, 1, + "the viewport moved while capturing the top page" + ); + let top_rows = fixture.assert_visible_avatar_rows(&top_snapshot, &top_png, "top page"); + assert!( + top_rows.len() >= 8, + "the first page must expose many fully visible synthetic rows: {:?}", + top_rows.iter().map(|row| row.index).collect::>() + ); + assert_eq!( + top_rows.first().map(|row| row.index), + Some(0), + "same-width chat orders must place Synthetic Group 01 first: {top_rows:?}" + ); + let top_text = snapshot_text(&top_snapshot); + assert!( + top_text.contains(&chat_title(0)), + "rendered root buffer is missing synthetic chat titles: {:.400}", + top_text + ); + + // --- Page down through real input: window-start advances each time. --- + let mut starts = vec![initial_start]; + let mut bottom_reached = false; + for page in 1..=6 { + if !fixture.page_down_advances_or_bottom() { + bottom_reached = true; + break; + } + let start = fixture.window_start(); + assert!( + start > *starts.last().expect("previous start"), + "PageDown {page} must advance window-start: {starts:?} -> {start}" + ); + starts.push(start); + let (snapshot, png, checked_start) = + fixture.corresponding_state(&format!("page-down-{page}")); + assert_eq!( + checked_start, start, + "viewport moved while capturing page {page}" + ); + let rows = fixture.assert_visible_avatar_rows(&snapshot, &png, &format!("page {page}")); + assert!( + !rows.is_empty(), + "page {page} (window-start {start}) must still render fully visible avatar rows" + ); + if fixture.window_end() >= fixture.buffer_size().saturating_add(1) { + bottom_reached = true; + break; + } + } + assert!( + starts.len() >= 3, + "the fixture must page through at least three viewports: {starts:?}" + ); + assert!( + bottom_reached, + "repeated PageDown must eventually reach the buffer end: {starts:?}" + ); + + // --- Page back up: window-start descends and returns to the top. --- + let mut page = 0; + while let Some(previous) = starts.pop() { + if previous == 1 { + break; + } + page += 1; + fixture.press_page_up(); + fixture.wait_for_window_start_below(previous); + let start = fixture.window_start(); + let (snapshot, png, checked_start) = + fixture.corresponding_state(&format!("page-up-{page}")); + assert_eq!( + checked_start, start, + "viewport moved while capturing page-up {page}" + ); + fixture.assert_visible_avatar_rows(&snapshot, &png, &format!("page-up {page}")); + } + fixture.wait_for_window_start(1); + assert_eq!( + fixture.window_start(), + 1, + "paging back up must return the viewport to the top: {starts:?}" + ); + let (returned_snapshot, returned_png, _) = fixture.corresponding_state("page-returned"); + let returned_rows = + fixture.assert_visible_avatar_rows(&returned_snapshot, &returned_png, "returned top page"); + assert_eq!( + returned_rows.first().map(|row| row.index), + Some(0), + "returning to the top must restore Synthetic Group 01 as the first row" + ); + fixture.assert_no_fixture_drift(); +} + +#[test] +fn telega_frontend_ingests_a_delayed_avatar_update_file() { + let fixture = Fixture::start(AvatarAvailability::DelayedUntilDownload, "delayed-ingest"); + fixture.wait_for_ready(); + + // Ready checkpoint: Telega asked the fixture to download the avatar. The + // fixture materializes the bytes only at that point, and completion is + // withheld until the test issues the explicit delivery checkpoint. + fixture.wait_for_mock(|log| { + log.iter().any(|line| { + line.record == "request" && line.type_name.as_deref() == Some("downloadFile") + }) + }); + let before = fixture.read_checkpoint().expect("checkpoint"); + assert_eq!( + before["avatar-spec-initials"].as_bool(), + Some(true), + "an undelivered avatar renders Telega's initials placeholder: {before}" + ); + assert_ne!( + before["file-table-downloaded"].as_bool(), + Some(true), + "Telega's file table must not report the avatar downloaded before delivery: {before}" + ); + assert_ne!( + before["avatar-file-downloaded"].as_bool(), + Some(true), + "the chat's photo file must not be completed before delivery: {before}" + ); + let (_, before_png, _) = fixture.corresponding_state("before-delivery"); + let before_pixels = PixelCounts::measure(&before_png); + assert_eq!( + before_pixels.of(AVATAR_COLOR), + 0, + "no undelivered avatar may render photo pixels: {before_pixels:?}" + ); + assert_eq!(before_pixels.of(REPLACED_CHAT_AVATAR_COLOR), 0); + + // Deliver chat 01's file; its unique color makes later assertions decisive. + let delivered_file_id = 5_000; + fixture.control(ControlCommand::DeliverFile { + file_id: delivered_file_id, + }); + fixture.wait_for_mock(|log| { + log.iter().any(|line| { + line.record == "event" + && line.type_name.as_deref() == Some("updateFile") + && line.file_id == Some(delivered_file_id) + }) + }); + + // The real frontend must ingest the update: Telega's own file table + // carries the delivered path and the cached avatar spec now references + // the photo. + let ingested = fixture.wait_for( + "waiting for Telega to ingest the delivered avatar", + Duration::from_secs(20), + || { + let checkpoint = fixture.read_checkpoint()?; + let path = checkpoint["file-table-path"].as_str()?; + let ready = checkpoint["file-table-downloaded"].as_bool() == Some(true) + && path.ends_with("avatar-5000.png") + && checkpoint["avatar-spec-references-photo"].as_bool() == Some(true); + ready.then_some(checkpoint) + }, + ); + assert_ne!( + ingested["avatar-spec-initials"].as_bool(), + Some(true), + "the delivered avatar must no longer be the initials placeholder: {ingested}" + ); + fixture.screenshot("after-delivery.png"); + fixture.assert_no_fixture_drift(); +} + +/// Delivery must repaint the photo, beyond merely updating Telega's file +/// table and cached SVG. No test-side image refresh is used. +#[test] +fn telega_frontend_repaints_a_delayed_avatar_after_update_file() { + let fixture = Fixture::start(AvatarAvailability::DelayedUntilDownload, "delayed-repaint"); + fixture.wait_for_ready(); + fixture.wait_for_mock(|log| { + log.iter().any(|line| { + line.record == "request" && line.type_name.as_deref() == Some("downloadFile") + }) + }); + fixture.control(ControlCommand::DeliverFile { file_id: 5_000 }); + fixture.wait_for_mock(|log| { + log.iter().any(|line| { + line.record == "event" + && line.type_name.as_deref() == Some("updateFile") + && line.file_id == Some(5_000) + }) + }); + fixture.wait_for( + "waiting for Telega to ingest the delivered avatar", + Duration::from_secs(20), + || { + let checkpoint = fixture.read_checkpoint()?; + (checkpoint["avatar-spec-references-photo"].as_bool() == Some(true)).then_some(()) + }, + ); + let delivered = fixture + .wait_for_pixels(|pixels| pixels.of(REPLACED_CHAT_AVATAR_COLOR) >= ONE_AVATAR_PIXELS); + assert_eq!( + delivered.of(AVATAR_COLOR), + 0, + "only the delivered avatar may show photo pixels: {delivered:?}" + ); + let (snapshot, png, _) = fixture.corresponding_state("after-delivery"); + let delivered_rows = + fixture.assert_visible_avatar_rows_with(&snapshot, &png, "delivered avatar", &|index| { + (index == 0).then_some(REPLACED_CHAT_AVATAR_COLOR) + }); + assert_eq!(delivered_rows.len(), 1, "only chat 01 was delivered"); + fixture.assert_no_fixture_drift(); +} + +#[test] +fn telega_frontend_replaces_a_chat_photo_without_restarting() { + let fixture = Fixture::start(AvatarAvailability::ReadyOnDisk, "replacement"); + fixture.wait_for_ready(); + + let (before_snapshot, before_png, _) = fixture.corresponding_state("before-replacement"); + let before_rows = + fixture.assert_visible_avatar_rows(&before_snapshot, &before_png, "before replacement"); + assert_eq!( + before_rows.first().map(|row| row.index), + Some(0), + "the replaced chat must be the first visible row" + ); + + fixture.control(ControlCommand::ReplacePhoto { chat_id: 1_000 }); + fixture.wait_for_mock(|log| { + log.iter().any(|line| { + line.record == "event" + && line.type_name.as_deref() == Some("updateChatPhoto") + && line.chat_id == Some(1_000) + }) + }); + fixture.wait_for_pixels(|pixels| pixels.of(REPLACEMENT_COLOR) >= ONE_AVATAR_PIXELS); + + // The first row now renders the replacement photo; every other fully + // visible row keeps its original avatar. + let (after_snapshot, after_png, _) = fixture.corresponding_state("after-replacement"); + let after_rows = fixture.assert_visible_avatar_rows_with( + &after_snapshot, + &after_png, + "after replacement", + &|index| { + if index == 0 { + Some(REPLACEMENT_COLOR) + } else { + Some(AVATAR_COLOR) + } + }, + ); + assert_eq!( + after_rows.iter().filter(|row| row.index == 0).count(), + 1, + "the replaced chat row must still be rendered exactly once" + ); + assert!( + !after_rows + .iter() + .any(|row| row.color == REPLACED_CHAT_AVATAR_COLOR), + "the old photo color must disappear from every visible row: {after_rows:?}" + ); + fixture.screenshot("after-replacement.png"); + fixture.assert_no_fixture_drift(); +} + +/// Teardown contract: a started fixture owns exactly one process group (the +/// editor plus the mock it spawned) and one private display. After teardown +/// neither may survive. +#[test] +fn fixture_tears_down_its_owned_process_group_and_display() { + let fixture = Fixture::start(AvatarAvailability::ReadyOnDisk, "teardown"); + fixture.wait_for_ready(); + let cleanup = CleanupProbe::capture(&fixture); + let mock_log = fixture.mock_log_path().to_path_buf(); + drop(fixture); + cleanup.assert_released(); + + let log = fs::read_to_string(&mock_log).unwrap_or_default(); + assert!( + log.lines().any(|line| { + serde_json::from_str::(line).is_ok_and(|record| { + record.record == "ready" + && matches!(record.stage.as_deref(), Some("eof") | Some("stopped")) + }) + }), + "the mock must observe the editor's pipe closing or an explicit stop" + ); +} + +/// A failing scenario body must still release the owned process group and the +/// private display through unwinding teardown. +#[test] +fn fixture_cleans_up_after_a_panicking_scenario() { + let cleanup_slot = Cell::new(None); + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let fixture = Fixture::start(AvatarAvailability::ReadyOnDisk, "failure-cleanup"); + fixture.wait_for_ready(); + cleanup_slot.set(Some(CleanupProbe::capture(&fixture))); + panic!("simulated fixture failure"); + })); + assert!(outcome.is_err(), "the simulated failure must unwind"); + cleanup_slot + .take() + .expect("capture cleanup evidence before the simulated failure") + .assert_released(); +} + +/// Keep credentials outside DisplaySession's cleanup directory so an +/// authentication failure cannot masquerade as successful display teardown. +struct CleanupProbe { + pgid: i32, + display: String, + authority: PathBuf, +} + +impl CleanupProbe { + fn capture(fixture: &Fixture) -> Self { + let authority = fixture.root.join("cleanup-probe.Xauthority"); + fs::copy( + fixture.env_value("XAUTHORITY").expect("private XAUTHORITY"), + &authority, + ) + .expect("preserve the private display credentials for teardown verification"); + fs::set_permissions(&authority, fs::Permissions::from_mode(0o600)) + .expect("restrict the copied private display credentials"); + let probe = Self { + pgid: fixture.pgid(), + display: fixture.env_value("DISPLAY").expect("private DISPLAY"), + authority, + }; + assert!( + process_group_alive(probe.pgid), + "the owned group must be live" + ); + assert!( + probe.display_alive(), + "the authenticated display probe must succeed before teardown" + ); + probe + } + + fn display_alive(&self) -> bool { + Command::new("xdpyinfo") + .env("DISPLAY", &self.display) + .env("XAUTHORITY", &self.authority) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("execute the authenticated display probe") + .success() + } + + fn assert_released(self) { + let deadline = Instant::now() + Duration::from_secs(10); + while process_group_alive(self.pgid) { + assert!( + Instant::now() < deadline, + "process group {} leaked after fixture teardown", + self.pgid + ); + thread::sleep(Duration::from_millis(50)); + } + let deadline = Instant::now() + Duration::from_secs(10); + while self.display_alive() { + assert!( + Instant::now() < deadline, + "private display {} leaked after fixture teardown", + self.display + ); + thread::sleep(Duration::from_millis(50)); + } + } +} + +// --------------------------------------------------------------------------- +// Fixture harness +// --------------------------------------------------------------------------- + +fn workspace_root() -> PathBuf { + neomacs_infra::workspace_root() +} + +fn neomacs_binary() -> PathBuf { + std::env::var_os("NEOMACS_GUI_TEST_BINARY") + .map(PathBuf::from) + .unwrap_or_else(|| workspace_root().join("target/release/neomacs")) +} + +fn mock_binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_neomacs-telega-fixture-mock")) +} + +fn process_group_alive(pgid: i32) -> bool { + // Signal 0 probes existence without delivering a signal. + unsafe { libc::kill(-pgid, 0) == 0 } +} + +fn signal_process_group(pgid: i32, signal: i32) { + // Only ever signals the group recorded at spawn time. + unsafe { + libc::kill(-pgid, signal); + } +} + +/// An owned runtime directory addressed through a held descriptor. On Linux +/// the published path is `/proc//fd/`, which is short enough for +/// Unix sockets at any checkout depth and still resolves to the owned +/// directory (verified by `assert_isolation`). +struct OwnedRuntimeDirectory { + path: PathBuf, + published: PathBuf, + _held: fs::File, +} + +impl OwnedRuntimeDirectory { + fn create(root: &Path) -> std::io::Result { + let path = root.join("runtime"); + fs::create_dir_all(&path)?; + fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?; + let held = fs::File::open(&path)?; + let published = PathBuf::from(format!( + "/proc/{}/fd/{}", + std::process::id(), + held.as_raw_fd() + )); + Ok(Self { + path, + published, + _held: held, + }) + } +} + +struct Fixture { + root: PathBuf, + provisioned_elpa: PathBuf, + session: neomacs_infra::display::DisplaySession, + runtime: OwnedRuntimeDirectory, + child: Option, + pgid: i32, + window_id: Option, + mock_log: PathBuf, + control: PathBuf, + checkpoint_path: PathBuf, + snapshot_path: PathBuf, + snapshot_ready: PathBuf, + snapshot_request: PathBuf, + stderr: PathBuf, + snapshot_sequence: Cell, +} + +impl Drop for Fixture { + fn drop(&mut self) { + self.shutdown(); + } +} + +impl Fixture { + fn start(availability: AvatarAvailability, name: &str) -> Self { + let workspace = workspace_root(); + let root = workspace + .join("target/neomacs-gui-tests") + .join(format!("tf-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("create fixture root"); + for directory in [ + "home", + "tmp", + "xdg/config", + "xdg/cache", + "xdg/data", + "xdg/state", + "telega-db", + "empty-elpa", + ] { + fs::create_dir_all(root.join(directory)).expect("create fixture subdirectory"); + } + let runtime = OwnedRuntimeDirectory::create(&root).expect("create owned runtime directory"); + + let session = DisplayHarness::for_backend(GuiBackend::LinuxX11) + .start_session(&root) + .expect("start the private Xvfb session"); + + // Provisioning is the only step allowed to touch the network, and it + // happens before the scenario starts, from the locked manifest. + let (telega_name, telega_version) = packages::TELEGA_PIN; + let driver = PathGnuDriver::resolve().expect("resolve the GNU Emacs install driver"); + let provisioned = packages::provision(&packages::pin(telega_name, telega_version), &driver) + .expect("provision pinned Telega from its locked source"); + let elpa_dir = provisioned.package_user_dir(); + let load_path = package_load_path(&elpa_dir); + + let paths = Paths::new(&root); + let mut command = Command::new(neomacs_binary()); + command + .args(["-Q", "-l"]) + .arg(workspace.join("crates/neomacs-gui-tests/fixtures/telega-fixture-gui.el")) + .envs(session.env().iter().map(|(k, v)| (k, v))) + .env("HOME", root.join("home")) + .env("TMPDIR", root.join("tmp")) + .env("XDG_RUNTIME_DIR", &runtime.published) + .env("XDG_CONFIG_HOME", root.join("xdg/config")) + .env("XDG_CACHE_HOME", root.join("xdg/cache")) + .env("XDG_DATA_HOME", root.join("xdg/data")) + .env("XDG_STATE_HOME", root.join("xdg/state")) + .env(FIXTURE_ROOT_ENV, &root) + .env(FIXTURE_SCENARIO_ENV, availability.name()) + .env("NEOMACS_TELEGA_MOCK", mock_binary()) + .env("NEOMACS_TELEGA_SOURCE", provisioned.source_file()) + .env("NEOMACS_TELEGA_LOAD_PATH", &load_path) + .env("NEOMACS_TELEGA_EMPTY_ELPA", root.join("empty-elpa")) + .env("NEOMACS_GUI_FRAME_SNAPSHOT_JSON", &paths.snapshot) + .env("WINIT_UNIX_BACKEND", "x11") + .env("RUST_LOG", "warn") + // Never route private tests at the user's session, bus, loading + // overrides, or package discovery paths. + .env_remove("WAYLAND_SOCKET") + .env_remove("DBUS_SESSION_BUS_ADDRESS") + .env_remove("EMACSLOADPATH") + .env_remove("EMACSNATIVELOADPATH") + .env_remove("EMACSDATA") + .env_remove("EMACSDOC") + .env_remove("EMACSPATH") + .env_remove("NEOMACS_PACKAGE_USER_DIR") + .env_remove("NEOMACS_DEBUG_FIRST_FRAME_READBACK") + .env_remove("NEOMACS_DEBUG_SURFACE_READBACK") + .env_remove("NEOMACS_DEBUG_SURFACE_READBACK_PNG") + .env_remove("NEOMACS_GUI_STATE_JSON") + .stdout(Stdio::from( + fs::File::create(&paths.stdout).expect("stdout log"), + )) + .stderr(Stdio::from( + fs::File::create(&paths.stderr).expect("stderr log"), + )) + // Own exactly one process group so teardown can never signal an + // unrelated process. + .process_group(0); + let child = command + .spawn() + .expect("spawn neomacs on the private display"); + let pgid = child.id() as i32; + + let mut fixture = Self { + root: root.clone(), + provisioned_elpa: elpa_dir, + session, + runtime, + child: Some(child), + pgid, + window_id: None, + mock_log: paths.mock_log, + control: paths.control, + checkpoint_path: paths.checkpoint, + snapshot_path: paths.snapshot, + snapshot_ready: paths.snapshot_ready, + snapshot_request: paths.snapshot_request, + stderr: paths.stderr, + snapshot_sequence: Cell::new(0), + }; + fixture.window_id = Some(fixture.wait_for_window()); + fixture + } + + fn pgid(&self) -> i32 { + self.pgid + } + + fn mock_log_path(&self) -> &Path { + &self.mock_log + } + + fn display_env(&self) -> Vec<(String, String)> { + self.session.env().to_vec() + } + + fn env_value(&self, key: &str) -> Option { + self.display_env() + .into_iter() + .find_map(|(name, value)| (name == key).then_some(value)) + } + + fn child_pid(&self) -> u32 { + self.child.as_ref().expect("child is running").id() + } + + fn wait_for_window(&mut self) -> String { + let pid = self.child_pid().to_string(); + let deadline = Instant::now() + Duration::from_secs(30); + loop { + self.assert_alive("waiting for the editor window"); + let mut command = Command::new("xdotool"); + command.args(["search", "--pid", &pid]); + for (key, value) in self.display_env() { + command.env(key, value); + } + if let Ok(output) = command.output() + && output.status.success() + && let Some(id) = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .map(str::to_string) + { + return id; + } + assert!( + Instant::now() < deadline, + "no private X11 window appeared for pid {pid}: {}", + self.diagnostics() + ); + thread::sleep(Duration::from_millis(50)); + } + } + + fn assert_alive(&mut self, what: &str) { + if let Some(child) = self.child.as_mut() + && let Some(status) = child.try_wait().expect("poll fixture child") + { + panic!( + "neomacs exited with {status} while {what}; scenario error: {}; stderr:\n{}", + self.scenario_error().unwrap_or_else(|| "none".to_string()), + self.stderr_tail() + ); + } + } + + fn scenario_error(&self) -> Option { + let contents = fs::read_to_string(self.root.join("scenario-error.json")).ok()?; + Some(contents.trim().to_string()) + } + + fn stderr_tail(&self) -> String { + let contents = fs::read_to_string(&self.stderr).unwrap_or_default(); + contents + .lines() + .rev() + .take(20) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join("\n") + } + + fn diagnostics(&self) -> String { + format!( + "scenario-error={:?}\nmock-log-tail:\n{}\nstderr-tail:\n{}", + self.scenario_error(), + self.mock_log_text() + .lines() + .rev() + .take(10) + .collect::>() + .join("\n"), + self.stderr_tail() + ) + } + + fn wait_for( + &self, + what: &str, + timeout: Duration, + mut probe: impl FnMut() -> Option, + ) -> T { + let deadline = Instant::now() + timeout; + loop { + if let Some(value) = probe() { + return value; + } + if let Some(error) = self.scenario_error() { + panic!("scenario failed while {what}: {error}"); + } + assert!( + Instant::now() < deadline, + "timed out after {timeout:?} while {what}; {}", + self.diagnostics() + ); + thread::sleep(Duration::from_millis(40)); + } + } + + fn read_checkpoint(&self) -> Option { + let bytes = fs::read(&self.checkpoint_path).ok()?; + serde_json::from_slice(&bytes).ok() + } + + /// Ready checkpoint: Telega finished fetching chats, the real root buffer + /// is displayed, and a fresh snapshot really shows rendered synthetic + /// rows. Readiness never depends on how many requests the frontend + /// happened to make (caching may legitimately avoid them). + fn wait_for_ready(&self) -> Value { + let checkpoint = self.wait_for( + "waiting for synthetic chats to load", + Duration::from_secs(90), + || { + let checkpoint = self.read_checkpoint()?; + let ready = checkpoint["chats-loaded"] == Value::Bool(true) + && checkpoint["chats"].as_u64() == Some(CHAT_COUNT as u64) + && checkpoint["selected-buffer"].as_str() == Some("*Telega Root*") + && checkpoint["window-start"].as_u64().is_some(); + ready.then_some(checkpoint) + }, + ); + self.wait_for_rendered_rows(8); + // Chat arrivals may preserve a point near the old insertion boundary. + // Establish the initial viewport through the real command binding. + self.press_key("ctrl+Home"); + self.wait_for_window_start(1); + self.read_checkpoint().unwrap_or(checkpoint) + } + + fn wait_for_rendered_rows(&self, minimum_titles: usize) -> Value { + self.wait_for( + &format!("waiting for {minimum_titles} rendered chat rows"), + Duration::from_secs(90), + || { + let snapshot = self.snapshot("ready"); + let rendered = rendered_rows(&snapshot) + .iter() + .filter(|row| synthetic_index(&row.text).is_some()) + .count(); + (rendered >= minimum_titles).then_some(snapshot) + }, + ) + } + + fn window_start(&self) -> u64 { + self.read_checkpoint() + .and_then(|checkpoint| checkpoint["window-start"].as_u64()) + .expect("checkpoint carries window-start") + } + + fn window_end(&self) -> u64 { + self.read_checkpoint() + .and_then(|checkpoint| checkpoint["window-end"].as_u64()) + .unwrap_or(0) + } + + fn buffer_size(&self) -> u64 { + self.read_checkpoint() + .and_then(|checkpoint| checkpoint["buffer-size"].as_u64()) + .unwrap_or(u64::MAX) + } + + fn wait_for_window_start(&self, expected: u64) { + self.wait_for( + &format!("waiting for window-start {expected}"), + Duration::from_secs(15), + || (self.window_start() == expected).then_some(()), + ); + } + + fn wait_for_window_start_below(&self, previous: u64) { + self.wait_for( + &format!("waiting for window-start below {previous}"), + Duration::from_secs(15), + || { + let start = self.window_start(); + (start < previous).then_some(()) + }, + ); + } + + /// Press PageDown once and wait for either a real advance or the buffer + /// bottom. Returns false when the bottom was reached without advancing. + fn page_down_advances_or_bottom(&self) -> bool { + let before = self.window_start(); + self.press_page_down(); + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let start = self.window_start(); + if start > before { + return true; + } + if self.window_end() >= self.buffer_size().saturating_add(1) { + return false; + } + assert!( + Instant::now() < deadline, + "PageDown neither advanced nor reached the buffer end; {}", + self.diagnostics() + ); + thread::sleep(Duration::from_millis(40)); + } + } + + fn press_key(&self, key: &str) { + let window = self.window_id.as_deref().expect("window id"); + self.xdotool(&["windowfocus", window]); + self.xdotool(&["key", key]); + } + + fn press_page_down(&self) { + self.press_key("Next"); + } + + fn press_page_up(&self) { + self.press_key("Prior"); + } + + fn xdotool(&self, args: &[&str]) { + let mut command = Command::new("xdotool"); + command.args(args); + for (key, value) in self.display_env() { + command.env(key, value); + } + let output = command + .output() + .expect("run xdotool on the private display"); + assert!( + output.status.success(), + "xdotool {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + fn request_snapshot(&self, token: &str) -> Value { + fs::write(&self.snapshot_request, token).expect("write snapshot request"); + self.wait_for( + &format!("waiting for frame snapshot {token}"), + Duration::from_secs(20), + || { + let ready = fs::read_to_string(&self.snapshot_ready).ok()?; + (ready.trim() == token).then_some(()) + }, + ); + let bytes = fs::read(&self.snapshot_path).expect("read frame snapshot"); + serde_json::from_slice(&bytes).expect("frame snapshot is JSON") + } + + fn snapshot(&self, label: &str) -> Value { + let sequence = self.snapshot_sequence.get() + 1; + self.snapshot_sequence.set(sequence); + self.request_snapshot(&format!("{label}-{}-{sequence}", std::process::id())) + } + + fn screenshot(&self, name: &str) -> PathBuf { + let window = self.window_id.as_deref().expect("window id"); + let path = self.root.join(name); + let mut command = Command::new("import"); + command.arg("-window").arg(window).arg(&path); + for (key, value) in self.display_env() { + command.env(key, value); + } + let output = command.output().expect("run import on the private display"); + assert!( + output.status.success() && path.is_file(), + "private-display screenshot failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + path + } + + /// Capture a snapshot and screenshot while the viewport is stationary. + fn corresponding_state(&self, label: &str) -> (Value, PathBuf, u64) { + for _ in 0..5 { + let start_before = self.window_start(); + let snapshot = self.snapshot(label); + let png = self.screenshot(&format!("{label}.png")); + let start_after = self.window_start(); + if start_before == start_after { + return (snapshot, png, start_after); + } + } + panic!("the viewport kept moving while capturing {label}"); + } + + /// Assert for every fully visible synthetic chat row that the row starts + /// with an avatar image glyph and that the expected color really appears + /// in that row's leading band of the corresponding private screenshot. + fn assert_visible_avatar_rows( + &self, + snapshot: &Value, + screenshot: &Path, + label: &str, + ) -> Vec { + self.assert_visible_avatar_rows_with(snapshot, screenshot, label, &|index| { + Some(expected_ready_color(index)) + }) + } + + fn assert_visible_avatar_rows_with( + &self, + snapshot: &Value, + screenshot: &Path, + label: &str, + expected: &dyn Fn(usize) -> Option<[u8; 3]>, + ) -> Vec { + let image = image::open(screenshot) + .expect("decode the private-display screenshot") + .to_rgb8(); + let rows = rendered_rows(snapshot); + assert!( + !rows.is_empty(), + "{label}: frame snapshot has no fully visible text rows" + ); + let mut verified = Vec::new(); + for row in rows { + let Some(index) = synthetic_index(&row.text) else { + continue; + }; + let Some(color) = expected(index) else { + continue; + }; + assert!( + row.first_glyph_is_image, + "{label}: {} ({}) must begin with an avatar image glyph: {:?}", + row.text, + chat_title(index), + row + ); + // Match the snapshot row/text to the image glyph's own bounds: + // its offset within the text area plus its realized advance. + let frame_y = row.text_area_y + row.pixel_y; + let image_x = row.text_area_x + row.first_glyph_offset_x; + // A zero advance means the glyph was not explicitly measured; + // fall back to the full leading band. The 16px floor keeps a + // column-width advance from shrinking the probe below the + // rendered avatar. + let image_width = if row.first_glyph_pixel_width > 0.0 { + row.first_glyph_pixel_width.clamp(16.0, AVATAR_BAND_WIDTH) + } else { + AVATAR_BAND_WIDTH + }; + let matched = + count_color_in_box(&image, image_x, frame_y, image_width, row.height_px, color); + assert!( + matched >= ONE_AVATAR_PIXELS, + "{label}: {} has only {matched} pixels of {color:?} inside its avatar \ + image bounds (x={image_x}, w={image_width}, row band y={frame_y}, h={}); \ + expected the avatar image to be visible there", + row.text, + row.height_px + ); + verified.push(VerifiedRow { index, color }); + } + verified + } + + fn wait_for_pixels(&self, predicate: impl Fn(&PixelCounts) -> bool) -> PixelCounts { + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let counts = PixelCounts::measure(&self.screenshot("pixel-poll.png")); + if predicate(&counts) { + return counts; + } + assert!( + Instant::now() < deadline, + "timed out waiting for rendered pixels: {counts:?}; {}", + self.diagnostics() + ); + thread::sleep(Duration::from_millis(100)); + } + } + + fn control(&self, command: ControlCommand) { + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&self.control) + .expect("open fixture control channel"); + let line = serde_json::to_string(&command).expect("encode control command"); + writeln!(file, "{line}").expect("append control command"); + file.flush().expect("flush control command"); + } + + fn mock_log(&self) -> Vec { + self.mock_log_text() + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect() + } + + fn mock_log_text(&self) -> String { + fs::read_to_string(&self.mock_log).unwrap_or_default() + } + + /// Whether the mock already recorded a terminal `ready` stage + /// (`eof`/`stopped`) for its session. + fn mock_session_ended(&self) -> bool { + self.mock_log_text().lines().any(|line| { + serde_json::from_str::(line).is_ok_and(|record| { + record.record == "ready" + && matches!(record.stage.as_deref(), Some("eof") | Some("stopped")) + }) + }) + } + + fn wait_for_mock(&self, predicate: impl Fn(&[LogLine]) -> bool) { + self.wait_for( + "waiting for a mock readiness checkpoint", + Duration::from_secs(30), + || predicate(&self.mock_log()).then_some(()), + ); + } + + fn assert_no_fixture_drift(&self) { + let log = self.mock_log(); + let unsupported: Vec<_> = log + .iter() + .filter(|line| line.record == "unsupported") + .collect(); + assert!( + unsupported.is_empty(), + "the fixture mock rejected {} unmodeled request(s): {unsupported:?}", + unsupported.len() + ); + let violations: Vec<_> = log + .iter() + .filter(|line| line.record == "violation") + .collect(); + assert!( + violations.is_empty(), + "the fixture mock rejected {} unknown address(es) or malformed input: {violations:?}", + violations.len() + ); + } + + /// Verify the process environment from /proc and the scenario's own + /// isolation report: the private display/runtime is really ours, and no + /// user session, bus, package, or loading override is reachable. + fn assert_isolation(&self) { + let pid = self.child_pid(); + let environ = fs::read(format!("/proc/{pid}/environ")).expect("read child environ"); + let environ: Vec<(String, String)> = environ + .split(|byte| *byte == 0) + .filter(|entry| !entry.is_empty()) + .filter_map(|entry| { + let text = String::from_utf8_lossy(entry); + let (key, value) = text.split_once('=')?; + Some((key.to_string(), value.to_string())) + }) + .collect(); + let value = |key: &str| { + environ + .iter() + .find_map(|(name, value)| (name == key).then_some(value.clone())) + }; + let private_display = self + .env_value("DISPLAY") + .expect("session publishes DISPLAY"); + assert_eq!( + value("DISPLAY").as_deref(), + Some(private_display.as_str()), + "the editor must run on the harness display" + ); + let runtime = value("XDG_RUNTIME_DIR").expect("runtime directory is set"); + assert_eq!( + fs::canonicalize(&runtime).expect("runtime directory resolves"), + fs::canonicalize(&self.runtime.path).expect("owned runtime directory"), + "the editor runtime directory must be the fixture-owned one" + ); + assert!( + !environ.iter().any(|(name, _)| name == "WAYLAND_SOCKET"), + "WAYLAND_SOCKET must be absent, not empty" + ); + assert!( + value("WAYLAND_DISPLAY").is_none_or(|display| display.is_empty()), + "WAYLAND_DISPLAY must not route to a user session" + ); + assert!( + value("DBUS_SESSION_BUS_ADDRESS").is_none(), + "the fixture must not reach the user's D-Bus session" + ); + assert!( + value("EMACSLOADPATH").is_none(), + "EMACSLOADPATH must be stripped so no personal load path is reachable" + ); + let home = value("HOME").expect("HOME is set"); + assert!( + home.starts_with(&self.root.to_string_lossy().to_string()), + "HOME {home} escapes the fixture root {}", + self.root.display() + ); + + let isolation: Value = serde_json::from_slice( + &fs::read(self.root.join("isolation.json")).expect("scenario isolation report"), + ) + .expect("isolation report is JSON"); + for key in [ + "telega-directory", + "telega-database-dir", + "telega-cache-dir", + ] { + let path = isolation[key].as_str().unwrap_or_default(); + assert!( + path.starts_with(&self.root.to_string_lossy().to_string()), + "{key} {path} escapes the fixture root" + ); + } + // The scenario reports the runtime directory it received; the + // authoritative check is that it canonicalizes to the owned directory + // even though it is published through /proc//fd/. + let isolation_runtime = isolation["runtime-dir"].as_str().unwrap_or_default(); + assert_eq!( + fs::canonicalize(isolation_runtime).expect("scenario runtime directory"), + fs::canonicalize(&self.runtime.path).expect("owned runtime directory"), + "the scenario runtime directory must be the fixture-owned one" + ); + assert_eq!( + isolation["server-command"].as_str(), + Some(mock_binary().to_string_lossy().as_ref()), + "Telega must launch the fixture mock through telega-server-command" + ); + assert_eq!(isolation["server-logfile"].as_str(), Some("nil")); + assert_eq!(isolation["graphic"].as_bool(), Some(true)); + assert_eq!( + isolation["root"].as_str(), + Some(self.root.to_string_lossy().as_ref()) + ); + let provisioned = self.provisioned_elpa.to_string_lossy().to_string(); + let source = isolation["source"].as_str().unwrap_or_default(); + assert!( + source.starts_with(&provisioned), + "the mounted Telega source {source} must come from the provisioned tree" + ); + let load_path = isolation["load-path"].as_array().expect("load-path report"); + assert!(!load_path.is_empty()); + for entry in load_path { + let entry = entry.as_str().unwrap_or_default(); + assert!( + entry.starts_with(&provisioned), + "load-path entry {entry} is outside the provisioned package tree" + ); + } + } + + fn shutdown(&mut self) { + if self.child.is_none() { + return; + } + // Graceful quit first; only then signal the recorded group. + let _ = fs::write(self.root.join("quit-request"), "quit"); + let deadline = Instant::now() + Duration::from_secs(5); + let mut exited = false; + if let Some(child) = self.child.as_mut() { + while Instant::now() < deadline { + if child.try_wait().ok().flatten().is_some() { + exited = true; + break; + } + thread::sleep(Duration::from_millis(50)); + } + } + if !exited { + signal_process_group(self.pgid, libc::SIGTERM); + let deadline = Instant::now() + Duration::from_secs(2); + if let Some(child) = self.child.as_mut() { + while Instant::now() < deadline { + if child.try_wait().ok().flatten().is_some() { + exited = true; + break; + } + thread::sleep(Duration::from_millis(50)); + } + } + } + if !exited { + signal_process_group(self.pgid, libc::SIGKILL); + } + if let Some(mut child) = self.child.take() { + let _ = child.wait(); + } + // Give the mock (a child of the editor in the same group) a moment to + // observe the closed pipe and record its terminal stage; then kill + // anything left in the recorded group. Never signal anything else. + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline && !self.mock_session_ended() { + thread::sleep(Duration::from_millis(25)); + } + if process_group_alive(self.pgid) { + signal_process_group(self.pgid, libc::SIGKILL); + } + self.window_id = None; + } +} + +impl std::fmt::Debug for Fixture { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Fixture") + .field("root", &self.root) + .field("pgid", &self.pgid) + .finish() + } +} + +fn package_load_path(elpa_dir: &Path) -> String { + let mut directories: Vec = fs::read_dir(elpa_dir) + .expect("read the provisioned elpa directory") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .map(|path| path.to_string_lossy().into_owned()) + .collect(); + directories.sort(); + assert!( + !directories.is_empty(), + "the provisioned elpa directory has no package trees" + ); + directories.join(":") +} + +struct Paths { + stdout: PathBuf, + stderr: PathBuf, + mock_log: PathBuf, + control: PathBuf, + checkpoint: PathBuf, + snapshot: PathBuf, + snapshot_ready: PathBuf, + snapshot_request: PathBuf, +} + +impl Paths { + fn new(root: &Path) -> Self { + Self { + stdout: root.join("neomacs.stdout.log"), + stderr: root.join("neomacs.stderr.log"), + mock_log: root.join("mock-log.jsonl"), + control: root.join("control.jsonl"), + checkpoint: root.join("checkpoint.json"), + snapshot: root.join("frame-snapshot.json"), + snapshot_ready: root.join("snapshot-ready"), + snapshot_request: root.join("snapshot-request"), + } + } +} + +/// One synthetic row whose avatar was verified in the corresponding +/// screenshot. +#[derive(Clone, Copy, Debug)] +struct VerifiedRow { + index: usize, + color: [u8; 3], +} + +/// A fully visible rendered text row from the frame snapshot. +#[derive(Clone, Debug)] +struct FrameRow { + text: String, + pixel_y: f32, + height_px: f32, + text_area_x: f32, + text_area_y: f32, + /// True when the first visible (non-stretch) glyph is an image. + first_glyph_is_image: bool, + /// Horizontal offset of that glyph inside the text area, in pixels. + first_glyph_offset_x: f32, + /// Realized advance of that glyph in pixels (0 when unmeasured). + first_glyph_pixel_width: f32, +} + +/// Index of the synthetic chat a row title names, ignoring UI chrome rows. +fn synthetic_index(text: &str) -> Option { + let start = text.find("Synthetic Group ")? + "Synthetic Group ".len(); + let number: String = text[start..] + .chars() + .take_while(char::is_ascii_digit) + .collect(); + let number: usize = number.parse().ok()?; + (1..=CHAT_COUNT).contains(&number).then_some(number - 1) +} + +/// Extract fully visible text rows (row band inside the window's text area) +/// from every window matrix of the snapshot. +fn rendered_rows(snapshot: &Value) -> Vec { + let mut rows = Vec::new(); + let Some(frames) = snapshot["frames"].as_array() else { + return rows; + }; + for frame in frames { + let Some(matrices) = frame["window_matrices"].as_array() else { + continue; + }; + for entry in matrices { + let Some(bounds) = entry.get("text_pixel_bounds") else { + continue; + }; + let (Some(area_x), Some(area_y), Some(area_h)) = ( + bounds["x"].as_f64(), + bounds["y"].as_f64(), + bounds["height"].as_f64(), + ) else { + continue; + }; + let Some(matrix_rows) = entry["matrix"]["rows"].as_array() else { + continue; + }; + for row in matrix_rows { + if row["enabled"].as_bool() == Some(false) + || row["mode_line"].as_bool() == Some(true) + // A row scrolled horizontally no longer starts at the + // line beginning; exclude it rather than mis-attributing + // the avatar position. + || row["truncated_left"].as_bool() == Some(true) + { + continue; + } + let (Some(pixel_y), Some(height_px)) = + (row["pixel_y"].as_f64(), row["height_px"].as_f64()) + else { + continue; + }; + // Deliberately exclude clipped partial rows: the row band must + // sit entirely inside the window's text area. + if pixel_y < 0.0 || pixel_y + height_px > area_h + 0.5 { + continue; + } + let Some(glyphs) = row["glyphs"].as_array() else { + continue; + }; + let Some(text_glyphs) = glyphs.get(1).and_then(Value::as_array) else { + continue; + }; + let mut text = String::new(); + let mut pen_x = 0.0_f32; + let mut first_visible: Option<(bool, f32, f32)> = None; + for glyph in text_glyphs { + append_glyph_text(glyph, &mut text); + let advance = glyph["pixel_width"].as_f64().unwrap_or(0.0) as f32; + // The avatar must be the first visible glyph of the line; + // stretch/filler glyphs before it do not occupy pixels. + if first_visible.is_none() && !is_stretch_glyph(glyph) { + first_visible = Some((is_image_glyph(glyph), pen_x, advance)); + } + pen_x += advance; + } + if text.trim().is_empty() { + continue; + } + let (first_glyph_is_image, first_glyph_offset_x, first_glyph_pixel_width) = + first_visible.unwrap_or((false, 0.0, 0.0)); + rows.push(FrameRow { + text, + pixel_y: pixel_y as f32, + height_px: height_px as f32, + text_area_x: area_x as f32, + text_area_y: area_y as f32, + first_glyph_is_image, + first_glyph_offset_x, + first_glyph_pixel_width, + }); + } + } + } + rows +} + +fn is_image_glyph(glyph: &Value) -> bool { + glyph + .get("glyph_type") + .and_then(Value::as_object) + .is_some_and(|variant| variant.contains_key("Image")) +} + +fn is_stretch_glyph(glyph: &Value) -> bool { + glyph + .get("glyph_type") + .and_then(Value::as_object) + .is_some_and(|variant| variant.contains_key("Stretch")) +} + +fn append_glyph_text(glyph: &Value, text: &mut String) { + let Some(variant) = glyph.get("glyph_type").and_then(Value::as_object) else { + return; + }; + if let Some(Value::Object(fields)) = variant.get("Char") + && let Some(Value::String(ch)) = fields.get("ch") + { + text.push_str(ch); + } + if let Some(Value::Object(fields)) = variant.get("Composite") + && let Some(Value::String(composite)) = fields.get("text") + { + text.push_str(composite); + } +} + +/// Count pixels of COLOR in the frame-relative box, clamped to the image. +fn count_color_in_box( + image: &image::RgbImage, + x: f32, + y: f32, + width: f32, + height: f32, + color: [u8; 3], +) -> usize { + let (image_width, image_height) = image.dimensions(); + let x0 = x.max(0.0).floor() as u32; + let y0 = y.max(0.0).floor() as u32; + let x1 = ((x + width).min(image_width as f32)).ceil() as u32; + let y1 = ((y + height).min(image_height as f32)).ceil() as u32; + let mut count = 0; + for py in y0..y1 { + for px in x0..x1 { + let pixel = image.get_pixel(px, py).0; + if pixel + .iter() + .zip(color.iter()) + .all(|(channel, target)| channel.abs_diff(*target) <= 6) + { + count += 1; + } + } + } + count +} + +/// Counts pixels of the fixture's exact avatar colors in a private-display +/// screenshot (whole-frame sanity counts). +#[derive(Clone, Copy, Debug, Default)] +struct PixelCounts { + pixels: [usize; 3], +} + +impl PixelCounts { + fn measure(path: &Path) -> Self { + let image = image::open(path) + .expect("decode the private-display screenshot") + .to_rgb8(); + let mut counts = [0_usize; 3]; + let targets = [AVATAR_COLOR, REPLACED_CHAT_AVATAR_COLOR, REPLACEMENT_COLOR]; + for pixel in image.pixels() { + let rgb = pixel.0; + for (index, target) in targets.iter().enumerate() { + if rgb + .iter() + .zip(target.iter()) + .all(|(channel, target)| channel.abs_diff(*target) <= 6) + { + counts[index] += 1; + } + } + } + Self { pixels: counts } + } + + fn of(&self, color: [u8; 3]) -> usize { + if color == AVATAR_COLOR { + self.pixels[0] + } else if color == REPLACED_CHAT_AVATAR_COLOR { + self.pixels[1] + } else if color == REPLACEMENT_COLOR { + self.pixels[2] + } else { + 0 + } + } +} + +/// Concatenated character text of a frame snapshot (glyph order). +fn snapshot_text(snapshot: &Value) -> String { + let mut text = String::new(); + fn walk(value: &Value, text: &mut String) { + match value { + Value::Object(map) => { + if map.contains_key("glyph_type") { + append_glyph_text(value, text); + } + for child in map.values() { + walk(child, text); + } + } + Value::Array(items) => { + for item in items { + walk(item, text); + } + } + _ => {} + } + } + walk(snapshot, &mut text); + text +} diff --git a/crates/neomacs-infra/src/packages/melpa-package-lock.tsv b/crates/neomacs-infra/src/packages/melpa-package-lock.tsv index 0fd0354f4e..a4516d8b9e 100644 --- a/crates/neomacs-infra/src/packages/melpa-package-lock.tsv +++ b/crates/neomacs-infra/src/packages/melpa-package-lock.tsv @@ -728,6 +728,7 @@ symon 20260411.1454 https://github.com/zk-phi/symon 294668d63da642276a0003cb4e9d tablist 20260623.1855 https://github.com/emacsorphanage/tablist 01f065e387ffe6b7a41f180f257cd12551c7a9c2 https://github.com/emacsorphanage/tablist 01f065e387ffe6b7a41f180f257cd12551c7a9c2 https://github.com/emacsmirror/tablist melpa-recipe - tagedit 20161121.855 https://github.com/magnars/tagedit b3a70101a0dcf85498c92b7fcfa7fdbac869746c https://github.com/magnars/tagedit b3a70101a0dcf85498c92b7fcfa7fdbac869746c https://github.com/emacsmirror/tagedit melpa-recipe dash,s tao-theme 20250717.347 https://github.com/11111000000/tao-theme-emacs 33c0d44048afe444e7a8aee30fbc101a00453799 https://github.com/11111000000/tao-theme-emacs 33c0d44048afe444e7a8aee30fbc101a00453799 https://github.com/emacsmirror/tao-theme-emacs melpa-recipe - +telega 20261002.1709 https://github.com/zevlg/telega.el a6abce419828fc63c7698c7d4951614e8d12d2ff https://github.com/zevlg/telega.el a6abce419828fc63c7698c7d4951614e8d12d2ff melpa-recipe transient,visual-fill-column tern 20260514.1348 https://github.com/ternjs/tern fab80daebd798b233a9a40d5a8b99359ace63b5e https://github.com/ternjs/tern fab80daebd798b233a9a40d5a8b99359ace63b5e https://github.com/emacsmirror/tern melpa-recipe - terraform-mode 20251115.2210 https://github.com/hcl-emacs/terraform-mode 01635df3625c0cec2bb4613a6f920b8569d41009 https://github.com/hcl-emacs/terraform-mode 01635df3625c0cec2bb4613a6f920b8569d41009 https://github.com/emacsmirror/terraform-mode melpa-recipe dash,hcl-mode tide 20260219.336 https://github.com/ananthakumaran/tide 9498c4c7fc97d8042fdff532f47f1dc79ebd163a https://github.com/ananthakumaran/tide 9498c4c7fc97d8042fdff532f47f1dc79ebd163a https://github.com/emacsmirror/tide melpa-recipe dash,flycheck,s diff --git a/crates/neomacs-infra/src/packages/mod.rs b/crates/neomacs-infra/src/packages/mod.rs index 8e3775fbae..3ce5c494fc 100644 --- a/crates/neomacs-infra/src/packages/mod.rs +++ b/crates/neomacs-infra/src/packages/mod.rs @@ -34,7 +34,7 @@ pub use install::{ }; pub use seal::{ProvisionedSealReport, verify_provisioned}; pub use source_lock::{ - LockedPackageSource, SourceBuild, locked_melpa_install_plan, locked_melpa_source, + LockedPackageSource, SourceBuild, TELEGA_PIN, locked_melpa_install_plan, locked_melpa_source, locked_melpa_sources, preflight_locked_melpa_packages, prepare_cached_locked_melpa_package, prepare_cached_locked_package_plan, }; diff --git a/crates/neomacs-infra/src/packages/source_lock.rs b/crates/neomacs-infra/src/packages/source_lock.rs index 7abef6c54b..120fa4b9ff 100644 --- a/crates/neomacs-infra/src/packages/source_lock.rs +++ b/crates/neomacs-infra/src/packages/source_lock.rs @@ -15,6 +15,11 @@ use crate::workspace_root; const LOCKED_PACKAGE_MANIFEST: &str = include_str!("melpa-package-lock.tsv"); static LOCKED_PACKAGE_CATALOG: OnceLock> = OnceLock::new(); +/// The pinned Telega frontend the account-free GUI fixture mounts. Tests and +/// the fixture harness must use this pin instead of a literal so the lock row +/// and its consumers cannot drift apart. +pub const TELEGA_PIN: (&str, &str) = ("telega", "20261002.1709"); + const MELPA_RECIPE_REPOSITORY: &str = "https://github.com/melpa/melpa"; const MELPA_RECIPE_REVISION: &str = "517749e477c16c0437cae029be71e672061a6c19"; const PACKAGE_BUILD_REPOSITORY: &str = "https://github.com/melpa/package-build"; diff --git a/crates/neomacs-infra/src/packages/source_lock/tests/source_lock_test.rs b/crates/neomacs-infra/src/packages/source_lock/tests/source_lock_test.rs index d5f33ef947..e2b4bcd0fd 100644 --- a/crates/neomacs-infra/src/packages/source_lock/tests/source_lock_test.rs +++ b/crates/neomacs-infra/src/packages/source_lock/tests/source_lock_test.rs @@ -402,3 +402,42 @@ fn concurrent_source_build_callers_share_one_failed_preparation() { "concurrent callers retried a known source build failure" ); } + +/// The account-free Telega GUI fixture mounts the real Telega Lisp frontend +/// from a pinned MELPA source. The lock row must therefore carry the exact +/// upstream revision and the full dependency closure Telega's +/// `Package-Requires' names, so an offline scenario never resolves a branch, +/// the user's package directory, or an unpinned dependency. +#[test] +fn telega_frontend_pin_resolves_to_the_pinned_source_and_dependency_closure() { + let pin = crate::packages::TELEGA_PIN; + let source = crate::packages::locked_melpa_source(pin) + .expect("telega has a revision-pinned source lock"); + assert_eq!( + source.upstream_repository(), + "https://github.com/zevlg/telega.el" + ); + assert_eq!( + source.upstream_revision(), + "a6abce419828fc63c7698c7d4951614e8d12d2ff" + ); + assert_eq!(source.build(), SourceBuild::MelpaRecipe); + + let plan = + crate::packages::locked_melpa_install_plan(pin).expect("telega install plan resolves"); + let names = plan + .iter() + .map(|locked| locked.package().0) + .collect::>(); + for dependency in ["telega", "transient", "visual-fill-column"] { + assert!( + names.contains(&dependency), + "telega install plan is missing {dependency}: {names:?}" + ); + } + assert_eq!( + names.last(), + Some(&"telega"), + "the install plan must end with the root package: {names:?}" + ); +}