pmacs/tests/m5_6_acceptance.rs

348 lines
14 KiB
Rust

// m5_6_acceptance.rs --- Acceptance suite for M5.6 (AttachTarget +
// pmacs.attach Lua surface + describe-instance + post-init dispatcher).
//! End-to-end acceptance tests for T M5.6 (`AttachTarget` enum, the
//! `pmacs.attach{...}` / `pmacs.current_attachment` / `pmacs.instance.*`
//! Lua surface, the describe-instance commands, and the post-init
//! attach dispatcher).
//!
//! The seven acceptance scenarios from the M5.6 plan:
//!
//! 1. **CLI attach** — `pmacs --attach --socket NAME` connects a local
//! frontend to a running daemon. **Covered by M5.5 acceptance**;
//! see `tests/m5_5_acceptance.rs::attach_send_key_receive_cell_response`
//! and `tests/m5_5_acceptance.rs::clean_detach_then_reattach`.
//! 2. **init.lua attach (`LocalSocket`) hands off** — an `init.lua`
//! containing `pmacs.attach{ target = "local:..." }` flows through
//! [`pmacs::config::load_user_config_at`], populates
//! [`pmacs::lua_bindings::RequestedAttach`], and the post-init
//! dispatcher returns `RunAttachLocalSocket(p)`. Tested here as
//! [`init_lua_local_socket_target_dispatches_to_attach_run`].
//! 3. **Mid-session attach error** — calling `pmacs.attach` after the
//! init-complete flag flips raises [`pmacs::lua_bindings::BindingError::InitOnlyApi`]
//! with a message pointing at the CLI workaround. Tested here as
//! [`attach_called_after_init_complete_yields_init_only_error`]
//! (in addition to the lib-level test
//! `lua_bindings::tests::attach_after_init_complete_errors_with_workaround_pointer`).
//! 4. **Echo describe** — `M-x editor.describe-instance` populates the
//! status row with `format_echo_line`. **Covered in lib tests:**
//! `editor::tests::editor_describe_instance_echoes_status_line` and
//! `instance_buffer::tests::echo_*`.
//! 5. **Buffer describe** — `M-x editor.describe-instance-buffer`
//! switches the active window to `*pmacs-instance*` and binds
//! buffer-local `q` to `buffer.kill-this`. **Covered in lib tests:**
//! `editor::tests::editor_describe_instance_buffer_switches_and_binds_q`
//! and `editor::tests::editor_describe_instance_buffer_q_kills_the_buffer`.
//! 6. **SSH dispatch (post-M5.7e)** — an `init.lua` containing
//! `pmacs.attach{ target = "ssh:host" }` parses + validates locally
//! and the post-init dispatcher routes it to
//! `AttachDispatch::RunAttachSsh(target)`. Tested here as
//! [`init_lua_ssh_target_dispatches_to_run_attach_ssh`]. (Pre-M5.7e
//! this scenario produced `DeferredInV01 { milestone = "M5.7" }`.)
//! 7. **`AlreadyAttached`** — a daemon with one frontend rejects a
//! second connection with `Goodbye(AlreadyAttached)`. **Covered by
//! M5.5 acceptance**; see
//! `tests/m5_5_acceptance.rs::attach_send_key_receive_cell_response`
//! (the second-attach branch).
//!
//! # Why some scenarios are cited and not duplicated
//!
//! Scenarios 1 and 7 require a real subprocess + socket and are
//! exercised end-to-end by `tests/m5_5_acceptance.rs`. Re-running
//! them under the M5.6 banner would be wasteful.
//!
//! Scenarios 4 and 5 require an [`pmacs::editor::EditorState`] with
//! the full builtin command surface loaded. The lib-level tests live
//! inside the `editor` and `instance_buffer` modules, where
//! `cfg(test)` short-circuits the user-config load so the test
//! environment doesn't pick up the developer's real `init.lua`.
//! Reproducing that environment from an integration test would
//! require mutating `XDG_CONFIG_HOME` (which pmacs forbids — `set_var`
//! is `unsafe` in edition 2024 and would race in parallel `cargo
//! test`); the lib-level coverage is the right place.
//!
//! Scenarios 2, 3, and 6 *are* end-to-end here: they drive a real
//! `init.lua` file through the real `load_user_config_at` →
//! `RequestedAttach` → `dispatch_attach` chain, which is the unique
//! integration story M5.6 introduces.
use std::path::PathBuf;
use pmacs::attach_dispatch::{AttachDispatch, dispatch_attach};
use pmacs::config::load_user_config_at;
use pmacs::lua::LuaHost;
// ---------------------------------------------------------------------------
// Scenario 2: init.lua attach (LocalSocket) hands off to attach mode.
// ---------------------------------------------------------------------------
#[test]
fn init_lua_local_socket_target_dispatches_to_attach_run() {
// Stage an init.lua that requests a local-socket attach. The
// path is fictitious — the dispatcher decides what to do based
// on the *kind* of target, not on whether the socket exists.
// Connection-attempt-time errors are M5.7's territory.
let dir = tempfile::TempDir::new().expect("tempdir");
std::fs::write(
dir.path().join("init.lua"),
r#"pmacs.attach { target = "local:/run/pmacs/work.sock" }"#,
)
.expect("write init.lua");
let mut host = LuaHost::new().expect("LuaHost::new");
// Mirror what `EditorState::new` does in production: run the
// config file, then flip the init-complete gate. Tests can't go
// through `EditorState::new` here because the integration-test
// build doesn't have `cfg(test)` set on the lib; the lib's own
// gate against picking up the developer's real init.lua only
// applies inside the lib's test target.
load_user_config_at(&mut host, dir.path());
host.set_init_complete();
assert!(
host.errors().is_empty(),
"init.lua produced errors: {:?}",
host.errors()
);
let requested = host
.take_requested_attach()
.expect("init.lua recorded an attach request");
match dispatch_attach(Some(requested)) {
AttachDispatch::RunAttachLocalSocket(path) => {
assert_eq!(path, PathBuf::from("/run/pmacs/work.sock"));
}
other => panic!("expected RunAttachLocalSocket, got {other:?}"),
}
}
#[test]
fn no_init_lua_attach_call_dispatches_to_run_local() {
// Symmetric to the previous test: when init.lua does *not* call
// `pmacs.attach`, the dispatcher falls through to RunLocal.
let dir = tempfile::TempDir::new().expect("tempdir");
std::fs::write(dir.path().join("init.lua"), "-- no attach here\n").expect("write init.lua");
let mut host = LuaHost::new().expect("LuaHost::new");
load_user_config_at(&mut host, dir.path());
host.set_init_complete();
assert!(
host.take_requested_attach().is_none(),
"no attach call → empty slot"
);
assert_eq!(dispatch_attach(None), AttachDispatch::RunLocal);
}
// ---------------------------------------------------------------------------
// Scenario 3: mid-session attach error.
// ---------------------------------------------------------------------------
#[test]
fn attach_called_after_init_complete_yields_init_only_error() {
// A user invoking `pmacs.attach` from a hook or M-x command (i.e.
// after init has finished) must hit the InitOnlyApi gate. The
// error message must name a workaround the user can act on
// ("restart with the equivalent CLI flag").
let mut host = LuaHost::new().expect("LuaHost::new");
host.set_init_complete();
let err = host
.eval(Some("test"), r#"pmacs.attach { target = "local:/x.sock" }"#)
.expect_err("post-init pmacs.attach must error");
let msg = err.to_string();
assert!(msg.contains("pmacs.attach"), "{msg}");
assert!(
msg.contains("init.lua"),
"error must name the right phase: {msg}"
);
assert!(
msg.contains("CLI flag"),
"error must point at the workaround: {msg}"
);
assert!(
host.take_requested_attach().is_none(),
"no request should have been recorded after a gated call"
);
}
#[test]
fn attach_called_during_init_then_dispatch_succeeds() {
// Companion test: confirm that the gate is *not* punitive — an
// attach call from init.lua (i.e. before set_init_complete) does
// populate the slot, and the post-init dispatch picks it up.
// Together with the previous test this pins both halves of the
// gate: closed after init, open during it.
let mut host = LuaHost::new().expect("LuaHost::new");
host.eval(
Some("test:init.lua"),
r#"pmacs.attach { target = "local:/run/pmacs/early.sock" }"#,
)
.expect("during-init pmacs.attach must succeed");
host.set_init_complete();
let target = host.take_requested_attach().expect("slot populated");
match dispatch_attach(Some(target)) {
AttachDispatch::RunAttachLocalSocket(p) => {
assert_eq!(p, PathBuf::from("/run/pmacs/early.sock"));
}
other => panic!("expected RunAttachLocalSocket, got {other:?}"),
}
}
// ---------------------------------------------------------------------------
// Scenario 6: SSH stub error at dispatch.
// ---------------------------------------------------------------------------
#[test]
fn init_lua_ssh_target_dispatches_to_run_attach_ssh() {
// M5.7e activates SSH: an init.lua-recorded SSH target now
// produces `RunAttachSsh(target)` from the post-init dispatcher,
// not the deferral that M5.6 shipped. The target round-trips
// through the dispatcher unchanged so `attach::run_attach_ssh`
// can build the SSH command from the same parsed fields.
let dir = tempfile::TempDir::new().expect("tempdir");
std::fs::write(
dir.path().join("init.lua"),
r#"pmacs.attach { target = "ssh:lev@example.com" }"#,
)
.expect("write init.lua");
let mut host = LuaHost::new().expect("LuaHost::new");
load_user_config_at(&mut host, dir.path());
host.set_init_complete();
assert!(
host.errors().is_empty(),
"ssh target should parse cleanly during init; errors: {:?}",
host.errors()
);
let requested = host
.take_requested_attach()
.expect("init.lua recorded the ssh attach");
match dispatch_attach(Some(requested)) {
AttachDispatch::RunAttachSsh(pmacs::protocol::AttachTarget::Ssh {
host,
user,
instance_name,
}) => {
assert_eq!(host, "example.com");
assert_eq!(user, Some("lev".into()));
assert!(instance_name.is_none());
}
other => panic!("expected RunAttachSsh(Ssh), got {other:?}"),
}
}
#[test]
fn init_lua_kwargs_form_ssh_dispatches_to_run_attach_ssh() {
// The kwargs form (`{ kind = "ssh", host = ..., user = ... }`)
// routes through the same dispatch as the target-string form
// and lands at `RunAttachSsh` post-M5.7e.
let dir = tempfile::TempDir::new().expect("tempdir");
std::fs::write(
dir.path().join("init.lua"),
r#"pmacs.attach { kind = "ssh", host = "mac-studio", user = "lev" }"#,
)
.expect("write init.lua");
let mut host = LuaHost::new().expect("LuaHost::new");
load_user_config_at(&mut host, dir.path());
host.set_init_complete();
let target = host.take_requested_attach().expect("slot populated");
match dispatch_attach(Some(target)) {
AttachDispatch::RunAttachSsh(pmacs::protocol::AttachTarget::Ssh { host, user, .. }) => {
assert_eq!(host, "mac-studio");
assert_eq!(user, Some("lev".into()));
}
other => panic!("expected RunAttachSsh(Ssh), got {other:?}"),
}
}
#[test]
fn init_lua_tls_target_defers_to_v0_2() {
// TLS shares the deferral pathway but with milestone "v0.2"
// rather than "M5.7" — that's where TLS lands per the project
// roadmap. The exact message text is part of the user contract.
let dir = tempfile::TempDir::new().expect("tempdir");
std::fs::write(
dir.path().join("init.lua"),
r#"pmacs.attach { target = "tls:example.com:9999#/etc/p.crt" }"#,
)
.expect("write init.lua");
let mut host = LuaHost::new().expect("LuaHost::new");
load_user_config_at(&mut host, dir.path());
host.set_init_complete();
let target = host.take_requested_attach().expect("slot populated");
let dispatch = dispatch_attach(Some(target));
match &dispatch {
AttachDispatch::DeferredInV01 { kind, milestone } => {
assert_eq!(*kind, "tls");
assert_eq!(*milestone, "v0.2");
}
other => panic!("expected DeferredInV01, got {other:?}"),
}
assert!(dispatch.deferred_message().unwrap().contains("v0.2"));
}
#[test]
fn init_lua_custom_target_defers_to_v0_2() {
// Symmetric to the TLS case: custom transports also defer to
// v0.2. Pinning all three deferred kinds (ssh, tls, custom)
// makes a future regression in any of the three loud, not silent.
let dir = tempfile::TempDir::new().expect("tempdir");
std::fs::write(
dir.path().join("init.lua"),
r#"pmacs.attach { kind = "custom", command = { "ssh", "host", "pmacs", "--daemon" } }"#,
)
.expect("write init.lua");
let mut host = LuaHost::new().expect("LuaHost::new");
load_user_config_at(&mut host, dir.path());
host.set_init_complete();
let target = host.take_requested_attach().expect("slot populated");
match dispatch_attach(Some(target)) {
AttachDispatch::DeferredInV01 { kind, milestone } => {
assert_eq!(kind, "custom");
assert_eq!(milestone, "v0.2");
}
other => panic!("expected DeferredInV01, got {other:?}"),
}
}
// ---------------------------------------------------------------------------
// Defensive: the slot is consumed exactly once.
// ---------------------------------------------------------------------------
#[test]
fn take_requested_attach_consumes_slot_exactly_once() {
// The dispatcher's contract is "consume the slot." Verify a
// second take() yields None — the dispatcher is single-shot, not
// a peek.
let dir = tempfile::TempDir::new().expect("tempdir");
std::fs::write(
dir.path().join("init.lua"),
r#"pmacs.attach { target = "local:/x.sock" }"#,
)
.unwrap();
let mut host = LuaHost::new().expect("LuaHost::new");
load_user_config_at(&mut host, dir.path());
host.set_init_complete();
let first = host.take_requested_attach();
let second = host.take_requested_attach();
assert!(first.is_some(), "first take should observe the request");
assert!(
second.is_none(),
"second take must be empty (single-shot semantics)"
);
}