pmacs/tests/m6_5_repl_acceptance.rs

479 lines
18 KiB
Rust

// m6_5_repl_acceptance.rs --- T M6.5 acceptance gates.
//! Acceptance gates for T M6.5 (REPL process integration).
//!
//! Spec §M6.5 acceptance bullets:
//!
//! 1. All four shells (bash, zsh, fish, lua) launch and produce a
//! usable session →
//! [`m6_5_repl_spawns_bash`], [`m6_5_repl_spawns_zsh`],
//! [`m6_5_repl_spawns_fish`], [`m6_5_repl_spawns_lua`].
//! 2. Input typed at prompt sent to process on Enter →
//! [`m6_5_ret_submits_input_to_process`].
//! 3. C-c sends SIGINT →
//! [`m6_5_ctrl_c_sends_sigint`].
//! 4. C-d on empty prompt closes stdin →
//! [`m6_5_ctrl_d_on_empty_prompt_closes_stdin`].
//!
//! Plus structural / lifecycle checks shared across stages:
//!
//! - [`m6_5_spawn_registers_process_on_supervisor`]: spawn returns a
//! handle whose `_proc_id` shows up in `pmacs.process.list()`.
//! - [`m6_5_close_terminates_child_and_unregisters`]: closing a handle
//! removes it from the supervisor and stops the after-tick pump
//! from drawing from it.
//! - [`m6_5_ctrl_d_on_nonempty_input_deletes_char_forward`][]: the
//! spec-literal "C-d on empty prompt closes stdin" is paired with
//! readline-equivalent "delete char forward" when input is non-empty,
//! so users never see C-d as broken.
//! - [`m6_5_exit_marker_uses_basename_with_leading_newline`][]: exit
//! marker is `"\n[<basename(argv[0])> exited with code N]\n"`.
use pmacs::editor::EditorState;
use std::fmt::Write as _;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, Instant};
static PUMP_TEST_LOCK: Mutex<()> = Mutex::new(());
/// Locate a shell binary for tests that require one. Returns the
/// resolved path or `None` if the shell is neither at `PMACS_TEST_<NAME>`
/// nor on `PATH`. Per-test selective skipping (rather than skipping
/// the whole file) so a missing bash doesn't hide a regression in zsh.
fn locate_shell(name: &str) -> Option<PathBuf> {
let env_var = format!("PMACS_TEST_{}", name.to_uppercase());
if let Ok(path) = std::env::var(&env_var) {
let p = PathBuf::from(path);
if p.is_file() {
return Some(p);
}
}
let out = std::process::Command::new("which")
.arg(name)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let path = String::from_utf8(out.stdout).ok()?;
let path = path.trim();
if path.is_empty() {
return None;
}
Some(PathBuf::from(path))
}
// ---------------------------------------------------------------------------
// Test harness
// ---------------------------------------------------------------------------
/// Construct a fresh editor and run the given Lua chunk against it.
fn run(chunk: &str) {
let mut editor = EditorState::new();
editor
.lua_host
.eval(Some("@m6_5_test"), chunk)
.expect("test chunk runs");
}
/// Run `setup_chunk`, then drive `tick_processes` (which fires
/// `process.after-tick` and runs the REPL pump) until `predicate_chunk`
/// returns truthy or the timeout elapses. Asserts the predicate became
/// true within the budget. The pump harness mirrors lsp.lua's
/// `poll_until` pattern but routes through `tick_processes` so the M6.5
/// after-tick contract is exercised end-to-end.
fn run_with_pump(setup_chunk: &str, predicate_chunk: &str, timeout_ms: u64) {
let _guard = PUMP_TEST_LOCK.lock().expect("pump test lock");
let mut editor = EditorState::new();
editor
.lua_host
.eval(Some("@m6_5_setup"), setup_chunk)
.expect("setup chunk runs");
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
loop {
editor.tick_processes();
let truthy: bool = editor
.lua_host
.lua()
.load(predicate_chunk)
.eval()
.expect("predicate chunk runs");
if truthy {
return;
}
assert!(
Instant::now() < deadline,
"pump predicate did not become true within {timeout_ms}ms; \
chunk:\n{predicate_chunk}"
);
std::thread::sleep(Duration::from_millis(5));
}
}
// ---------------------------------------------------------------------------
// Stage 2: spawn structure + lifecycle wiring
// ---------------------------------------------------------------------------
/// pmacs.repl.spawn produces a handle whose process is visible to the
/// supervisor and whose buffer is a real pmacs buffer. This is the
/// minimum viability check before per-byte routing tests.
#[test]
fn m6_5_spawn_registers_process_on_supervisor() {
run(r#"
local h = pmacs.repl.spawn { argv = { "cat" } }
assert(h._proc_id ~= nil, "handle missing _proc_id")
assert(type(h:buffer_id()) ~= "nil", "handle missing buffer_id")
-- Process should appear in the supervisor's list.
local list = pmacs.process.list()
local found = false
for _, row in ipairs(list) do
if row.id == h._proc_id then
found = true
break
end
end
assert(found, "spawned process not in pmacs.process.list()")
h:close()
"#);
}
/// Acceptance bullet 2: input typed at prompt is sent to the process
/// on Enter, and the process's response (via raw-mode echo for `cat`,
/// shell line editor for bash/zsh/fish) lands in history.
///
/// Process startup is not synchronous: the supervisor returns a
/// handle immediately and the actual `exec` happens later. The
/// predicate chunk gates submission on the `Running` state via a
/// `_G.submitted` flag so we don't write to stdin before the child
/// is alive.
#[test]
fn m6_5_ret_submits_input_to_process() {
run_with_pump(
r#"
_G.h = pmacs.repl.spawn { argv = { "cat" } }
_G.submitted = false
"#,
r#"
local h = _G.h
if not _G.submitted then
local status = pmacs.process.status(h._proc_id)
if status and status.kind == "running" then
h:buffer_id():insert(h:prompt_end(), "hello")
pmacs.command.invoke("pmacs.repl.submit-current")
_G.submitted = true
end
return false
end
local buf = h:buffer_id()
local history = buf:slice(0, h:history_end())
return history:find("hello", 1, true) ~= nil
"#,
3000,
);
}
/// Acceptance bullet 4: C-d on an empty prompt closes stdin. The
/// implementation writes `\x04` (EOT) to the PTY master. In raw mode
/// the kernel does not interpret this byte (no ICANON), but every
/// shell with a line editor (readline / zle / fish-internal) treats
/// `\x04` on an empty input line as EOF and exits. The contract is
/// thus shell-mediated: the package emits the byte; the shell decides
/// what it means. Verified by spawning bash and observing the exit
/// marker.
///
/// Skipped if bash isn't available (Stage 5's multi-shell suite has
/// fuller coverage). `PMACS_TEST_BASH` overrides the PATH lookup.
#[test]
fn m6_5_ctrl_d_on_empty_prompt_closes_stdin() {
let Some(bash) = locate_shell("bash") else {
eprintln!("skipping: bash not on PATH (set PMACS_TEST_BASH to override)");
return;
};
let setup = format!(
r#"
_G.h = pmacs.repl.spawn {{ argv = {{ "{bash}", "-i" }} }}
_G.eof_sent = false
_G.first_seen_running_at = nil
"#,
bash = bash.display(),
);
run_with_pump(
&setup,
r#"
local h = _G.h
if not _G.eof_sent then
local status = pmacs.process.status(h._proc_id)
if status and status.kind == "running" then
-- Wait two ticks past first-running so bash gets to
-- start its line editor and print its prompt before
-- we send EOF. Without the warmup, EOF can race with
-- readline initialization on slow CI runners.
if _G.first_seen_running_at == nil then
_G.first_seen_running_at = pmacs.now_ms()
return false
end
if pmacs.now_ms() - _G.first_seen_running_at < 100 then
return false
end
pmacs.command.invoke("pmacs.repl.send-eof-current")
_G.eof_sent = true
end
return false
end
-- bash exits cleanly on EOF (code 0) after readline EOT.
-- The exit marker lives at the end of history; searching
-- for the basename + " exited" anchors us on the marker.
local buf = h:buffer_id()
local history = buf:slice(0, h:history_end())
return history:find("[bash exited", 1, true) ~= nil
"#,
5000,
);
}
/// C-d when the input region is non-empty deletes the character at
/// the cursor (readline-equivalent), not stdin. This is the package
/// choice for the case the spec is silent on; the wrong choice is
/// "no-op", which makes C-d feel broken to anyone with terminal
/// muscle memory. Empty-vs-nonempty branch lives in
/// `pmacs.repl.send-eof-current`.
#[test]
fn m6_5_ctrl_d_on_nonempty_input_deletes_char_forward() {
run(r#"
local h = pmacs.repl.spawn { argv = { "cat" } }
h:buffer_id():insert(h:prompt_end(), "hello")
-- Walk the cursor back to the start of the input region.
-- `insert` leaves cursor position untouched; the editor's
-- default cursor at construction is byte 0, which is exactly
-- prompt_end here. But to keep the test robust to future
-- cursor-default changes, walk to line start explicitly.
pmacs.editor.move_line_start()
for _ = 1, 100 do pmacs.editor.move_up() end
assert(h:input_text() == "hello",
"pre: input_text=" .. h:input_text())
pmacs.command.invoke("pmacs.repl.send-eof-current")
-- One char dropped from the front; "ello" remains.
assert(h:input_text() == "ello",
"post: input_text=" .. h:input_text())
h:close()
"#);
}
/// Acceptance bullet 3: C-c sends SIGINT. We spawn `cat` (which
/// terminates on SIGINT) and verify the exit marker reports the
/// expected signal. The signal name in the marker is symbolic
/// ("SIGINT") rather than a number, per the M6.5 design (the libc
/// description "Interrupt" returned by portable-pty is canonicalized
/// in `process.rs::canonicalize_pty_signal_name`).
///
/// Timeout is 10s to absorb supervisor-tick scheduling under heavy
/// parallel test load (the M6.1 PTY suite has a similar flake
/// profile under `cargo test`'s default parallelism).
#[test]
fn m6_5_ctrl_c_sends_sigint() {
run_with_pump(
r#"
_G.h = pmacs.repl.spawn { argv = { "cat" } }
_G.sigint_sent = false
"#,
r#"
local h = _G.h
if not _G.sigint_sent then
local status = pmacs.process.status(h._proc_id)
if status and status.kind == "running" then
pmacs.command.invoke("pmacs.repl.send-sigint-current")
_G.sigint_sent = true
end
return false
end
local buf = h:buffer_id()
local history = buf:slice(0, h:history_end())
return history:find("killed by SIGINT", 1, true) ~= nil
"#,
10_000,
);
}
/// The exit marker uses `basename(argv[0])` (so `/usr/bin/cat`
/// renders as `cat`), leads with `\n` (so a process exiting mid-line
/// stays readable), and uses symbolic signal names. Verified by
/// spawning `/bin/false`, which exits with code 1.
#[test]
fn m6_5_exit_marker_uses_basename_with_leading_newline() {
run_with_pump(
r#"
_G.h = pmacs.repl.spawn { argv = { "/bin/false" } }
"#,
r#"
local h = _G.h
local buf = h:buffer_id()
local history = buf:slice(0, h:history_end())
-- Marker starts with "\n[false exited with code 1]\n".
return history:find("\n[false exited with code 1]\n", 1, true) ~= nil
"#,
3000,
);
}
// ---------------------------------------------------------------------------
// Stage 5: multi-shell acceptance (acceptance bullet 1)
// ---------------------------------------------------------------------------
/// Run a single command (`echo hello-pmacs`) through a freshly-spawned
/// shell and verify the output appears in history. The shell exits
/// itself via the same EOF byte the C-d binding sends, so we don't
/// need to wire up `exit` per-shell. Predicate gates first on
/// running-state (process must have started before we type) and then
/// on history matching the expected output.
fn run_shell_smoke_test(shell_path: &std::path::Path, argv_extra: &[&str]) {
let mut argv_lua = String::new();
write!(&mut argv_lua, r#""{}""#, shell_path.display()).unwrap();
for a in argv_extra {
write!(&mut argv_lua, r#", "{a}""#).unwrap();
}
let setup = format!(
r"
_G.h = pmacs.repl.spawn {{ argv = {{ {argv_lua} }} }}
_G.typed = false
",
);
run_with_pump(
&setup,
r#"
local h = _G.h
if not _G.typed then
local status = pmacs.process.status(h._proc_id)
if status and status.kind == "running" then
-- Type the smoke command into the input region and
-- submit. After-tick will drain the shell's echo and
-- the command's output into history.
h:buffer_id():insert(h:prompt_end(), "echo hello-pmacs")
pmacs.command.invoke("pmacs.repl.submit-current")
_G.typed = true
end
return false
end
local buf = h:buffer_id()
local history = buf:slice(0, h:history_end())
return history:find("hello-pmacs", 1, true) ~= nil
"#,
// 15s timeout absorbs the slowest-shell case (fish under
// heavy parallel load); none of the four shells is normally
// anywhere near that, but the test must not flake when 5
// shell tests + the SIGINT test all start their PTY children
// in the same cargo-test thread pool.
15_000,
);
}
/// Bash. Required-ish — present on virtually every Linux system. Skip
/// only if neither `PMACS_TEST_BASH` nor PATH resolves it.
#[test]
fn m6_5_repl_spawns_bash() {
let Some(bash) = locate_shell("bash") else {
eprintln!("skipping: bash not on PATH (set PMACS_TEST_BASH to override)");
return;
};
run_shell_smoke_test(&bash, &["-i"]);
}
/// Zsh. Skip if not installed.
#[test]
fn m6_5_repl_spawns_zsh() {
let Some(zsh) = locate_shell("zsh") else {
eprintln!("skipping: zsh not on PATH (set PMACS_TEST_ZSH to override)");
return;
};
run_shell_smoke_test(&zsh, &["-i"]);
}
/// Fish. Historically the most fussy of the four about TTY setup;
/// pmacs's PTY raw mode should satisfy it.
#[test]
fn m6_5_repl_spawns_fish() {
let Some(fish) = locate_shell("fish") else {
eprintln!("skipping: fish not on PATH (set PMACS_TEST_FISH to override)");
return;
};
run_shell_smoke_test(&fish, &["-i"]);
}
/// Lua REPL. Lua is a pmacs build dep so this MUST work. The Lua
/// interpreter doesn't have a "echo hello-pmacs" syntax; we use
/// `print("hello-pmacs")` instead via a custom test path.
#[test]
fn m6_5_repl_spawns_lua() {
let Some(lua) = locate_shell("lua").or_else(|| locate_shell("luajit")) else {
panic!(
"lua/luajit must be on PATH for the M6.5 lua REPL acceptance test \
(set PMACS_TEST_LUA or PMACS_TEST_LUAJIT to override)"
);
};
let setup = format!(
r#"
_G.h = pmacs.repl.spawn {{ argv = {{ "{lua}", "-i" }} }}
_G.typed = false
"#,
lua = lua.display(),
);
run_with_pump(
&setup,
r#"
local h = _G.h
if not _G.typed then
local status = pmacs.process.status(h._proc_id)
if status and status.kind == "running" then
h:buffer_id():insert(h:prompt_end(), "print('hello-pmacs')")
pmacs.command.invoke("pmacs.repl.submit-current")
_G.typed = true
end
return false
end
local buf = h:buffer_id()
local history = buf:slice(0, h:history_end())
return history:find("hello-pmacs", 1, true) ~= nil
"#,
10_000,
);
}
// ---------------------------------------------------------------------------
// Lifecycle regressions
// ---------------------------------------------------------------------------
/// Closing a handle removes it from the supervisor's view and from the
/// per-frame pump registry, so subsequent ticks do not try to drain a
/// dead process.
#[test]
fn m6_5_close_terminates_child_and_unregisters() {
// M6.9 audit shape: close() sends terminate and sets _closing; the
// actual proc_pump removal, _proc_id clearing, and forget call
// happen in _on_exit (driven by the after-tick hook observing the
// exit event). This routes the supervisor's terminal-state record
// through forget so it doesn't leak across spawn-close cycles.
// Pre-M6.9 close() eagerly cleared _proc_id, but that pre-empted
// _on_exit and caused the supervisor to retain dead-process
// records forever.
run(r#"
local h = pmacs.repl.spawn { argv = { "cat" } }
local raw = h._proc_id:raw()
h:close()
-- close() set _closing; bound commands check this and no-op.
assert(h._closing == true, "close() should set _closing")
-- Walking the after-tick hook must not raise: even though
-- the handle is still registered (until _on_exit fires),
-- drain_handle is robust to the closing-but-not-yet-exited
-- state.
pmacs.hook.run("process.after-tick")
"#);
}