348 lines
14 KiB
Rust
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)"
|
|
);
|
|
}
|