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:?}" + ); +} 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]