// 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[ exited with code N]\n"`. use pmacs::editor::EditorState; use std::fmt::Write as _; use std::path::PathBuf; use std::sync::{Mutex, MutexGuard}; use std::time::{Duration, Instant}; static PUMP_TEST_LOCK: Mutex<()> = Mutex::new(()); fn pump_test_guard() -> MutexGuard<'static, ()> { PUMP_TEST_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } /// Locate a shell binary for tests that require one. Returns the /// resolved path or `None` if the shell is neither at `PMACS_TEST_` /// 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 { 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 _guard = pump_test_guard(); 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_guard(); 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 `sleep` (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] #[cfg_attr( target_os = "macos", ignore = "macOS hosted PTYs do not reliably surface this non-interactive SIGINT marker" )] fn m6_5_ctrl_c_sends_sigint() { let Some(sleep) = locate_shell("sleep") else { eprintln!("skipping: sleep not on PATH (set PMACS_TEST_SLEEP to override)"); return; }; let setup = format!( r#" _G.h = pmacs.repl.spawn {{ argv = {{ "{sleep}", "30" }} }} _G.sigint_sent = false _G.first_seen_running_at = nil "#, sleep = sleep.display(), ); run_with_pump( &setup, 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 -- Running means the PTY child has been spawned, not -- that the raw-mode /bin/sh trampoline has necessarily -- completed stty + exec. macOS runners can observe that -- gap; wait briefly so SIGINT targets cat, not the -- setup shell. 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-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 `false`, which exits with code 1. #[test] #[cfg_attr( target_os = "macos", ignore = "macOS hosted PTYs do not reliably surface this non-interactive exit marker" )] fn m6_5_exit_marker_uses_basename_with_leading_newline() { let Some(false_bin) = locate_shell("false") else { eprintln!("skipping: false not on PATH (set PMACS_TEST_FALSE to override)"); return; }; let setup = format!( r#" _G.h = pmacs.repl.spawn {{ argv = {{ "{false_bin}" }} }} "#, false_bin = false_bin.display(), ); run_with_pump( &setup, 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 "#, // PTY shutdown publishes the exit event only after a bounded // final-output drain. macOS runners can spend most of the old // 3s budget in spawn + that drain, even for /bin/false. 10_000, ); } // --------------------------------------------------------------------------- // 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. Skip if no standalone Lua interpreter is installed. /// The embedded Lua build dependency does not guarantee a `lua` or /// `luajit` executable on CI images. #[test] fn m6_5_repl_spawns_lua() { let Some(lua) = locate_shell("lua").or_else(|| locate_shell("luajit")) else { eprintln!( "skipping: lua/luajit not on PATH (set PMACS_TEST_LUA or PMACS_TEST_LUAJIT to override)" ); return; }; 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") "#); }