pmacs/tests/m8_1d_acceptance.rs

879 lines
34 KiB
Rust

// tests/m8_1d_acceptance.rs --- T M8.1d reload + on_unload acceptance.
//! Acceptance tests for `pmacs.packages.reload(name)` and
//! `pmacs.packages.on_unload(fn)`.
//!
//! Reload is the dev-loop completion: combined with M8.1c's
//! `install_local`, it lets the M8.2-M8.10 package author edit
//! source files on disk and observe the change without restarting
//! pmacs. The `on_unload` hook gives packages with non-Lua state
//! (running children, open file handles) a place to clean up.
//!
//! The contracts pinned here:
//!
//! - `reload(name)` clears `package.loaded[name]` (and submodules)
//! then re-`require`s, so changes on disk become visible.
//! - `on_unload(fn)` registers a per-package callback recovered
//! from the calling chunk's `_PACKAGE.name` env field --- no
//! manual basename argument required from the package author.
//! - Hooks fire in registration order on the first reload after
//! they were registered, then are cleared so the re-loaded
//! chunk's fresh hooks own the next cycle.
//! - `reload` of a name not in the roster errors clearly.
//! - `on_unload` called from non-package code errors clearly with
//! a pointer at the shutdown-hook alternative.
use std::path::PathBuf;
use pmacs::lua::LuaHost;
use pmacs::lua_bindings::PackageInstallOverride;
use tempfile::TempDir;
fn make_local_pkg_with_init(name: &str, init_body: &str) -> (TempDir, PathBuf) {
let td = tempfile::tempdir().expect("tempdir");
let dir = td.path().to_path_buf();
let manifest = format!(
"name = \"{name}\"\n\
version = \"1.0.0\"\n\
summary = \"M8.1d reload fixture: {name}\"\n\
pmacs_required = \">= 0.1.0\"\n\
entry = \"init.lua\"\n\
exports = [\"{name}\"]\n"
);
std::fs::write(dir.join("pmacs.toml"), manifest).expect("write manifest");
std::fs::write(dir.join("init.lua"), init_body).expect("write entry");
(td, dir)
}
fn host_with_overrides() -> (LuaHost, TempDir, TempDir) {
let cache = tempfile::tempdir().expect("cache tempdir");
let user_root = tempfile::tempdir().expect("user-root tempdir");
let host = LuaHost::new().expect("LuaHost::new");
host.set_package_install_override(
PackageInstallOverride::new()
.with_cache_dir(cache.path().to_path_buf())
.with_user_install_root(user_root.path().to_path_buf()),
);
(host, cache, user_root)
}
// ---------------------------------------------------------------------------
// T M8.1d --- the dev-loop test: edit on disk, reload, see new behavior
// ---------------------------------------------------------------------------
#[test]
fn reload_picks_up_edits_to_install_local_source_on_disk() {
let (_pkg_td, pkg_path) = make_local_pkg_with_init(
"reload-disk",
"return { name = 'reload-disk', marker = 'before-edit' }\n",
);
let pkg_str = pkg_path.display().to_string();
let (mut host, _c, _u) = host_with_overrides();
let warm = format!(
r#"
pmacs.packages.install_local("{pkg_str}")
local m = require("reload-disk")
assert(m.marker == "before-edit", "warm-up should see 'before-edit'")
"#
);
host.eval(Some("warm"), &warm)
.unwrap_or_else(|e| panic!("warm-up failed: {e}"));
// Edit the source on disk. This is the entire point of
// install_local + reload: the editor running pmacs is
// simulating the M8 package author iterating on their package
// without restarting.
std::fs::write(
pkg_path.join("init.lua"),
"return { name = 'reload-disk', marker = 'after-edit' }\n",
)
.expect("re-write entry");
let reloaded = r#"
local m = pmacs.packages.reload("reload-disk")
assert(m.marker == "after-edit",
"reload should yield 'after-edit', got " .. tostring(m.marker))
-- And a regular require post-reload returns the same bytes.
local m2 = require("reload-disk")
assert(m2.marker == "after-edit", "post-reload require should also see 'after-edit'")
"#;
host.eval(Some("reload"), reloaded)
.unwrap_or_else(|e| panic!("reload after edit failed: {e}"));
}
// ---------------------------------------------------------------------------
// T M8.1d --- on_unload runs in registration order on reload
// ---------------------------------------------------------------------------
#[test]
fn on_unload_hooks_run_in_registration_order_on_reload() {
// Package's init registers two on_unload hooks that each push
// a marker onto a global. Reload should see both fire in the
// order they were registered.
let (_pkg_td, pkg_path) = make_local_pkg_with_init(
"reload-hooks",
r#"
_G.RELOAD_HOOK_LOG = _G.RELOAD_HOOK_LOG or {}
pmacs.packages.on_unload(function()
table.insert(_G.RELOAD_HOOK_LOG, "first")
end)
pmacs.packages.on_unload(function()
table.insert(_G.RELOAD_HOOK_LOG, "second")
end)
return { name = 'reload-hooks' }
"#,
);
let pkg_str = pkg_path.display().to_string();
let (mut host, _c, _u) = host_with_overrides();
let script = format!(
r#"
pmacs.packages.install_local("{pkg_str}")
require("reload-hooks") -- registers two on_unload hooks
assert(#_G.RELOAD_HOOK_LOG == 0, "hooks haven't fired yet")
pmacs.packages.reload("reload-hooks")
assert(#_G.RELOAD_HOOK_LOG == 2, "exactly two hooks ran; got " .. #_G.RELOAD_HOOK_LOG)
assert(_G.RELOAD_HOOK_LOG[1] == "first", "first hook should fire first")
assert(_G.RELOAD_HOOK_LOG[2] == "second", "second hook should fire second")
"#
);
host.eval(Some("hooks"), &script)
.unwrap_or_else(|e| panic!("on_unload ordering failed: {e}"));
}
// ---------------------------------------------------------------------------
// T M8.1d --- hook list is consumed on reload (re-loaded chunk re-registers)
// ---------------------------------------------------------------------------
#[test]
fn on_unload_hooks_consumed_on_reload_then_freshly_registered() {
// Without consume-on-reload, the hooks would accumulate: each
// reload would fire all prior hooks plus the freshly-registered
// ones. The contract is: each load registers fresh hooks; each
// reload consumes whatever was registered since the last load.
let (_pkg_td, pkg_path) = make_local_pkg_with_init(
"reload-consume",
r#"
_G.CONSUME_LOG = _G.CONSUME_LOG or {}
pmacs.packages.on_unload(function()
table.insert(_G.CONSUME_LOG, "tick")
end)
return { name = 'reload-consume' }
"#,
);
let pkg_str = pkg_path.display().to_string();
let (mut host, _c, _u) = host_with_overrides();
let script = format!(
r#"
pmacs.packages.install_local("{pkg_str}")
require("reload-consume") -- registers 1 hook
pmacs.packages.reload("reload-consume") -- runs 1, registers 1 (chunk re-runs)
pmacs.packages.reload("reload-consume") -- runs 1, registers 1
pmacs.packages.reload("reload-consume") -- runs 1, registers 1
assert(#_G.CONSUME_LOG == 3,
"expected 3 ticks across 3 reloads (hook list consumed each time); got " ..
#_G.CONSUME_LOG)
"#
);
host.eval(Some("consume"), &script)
.unwrap_or_else(|e| panic!("hook consumption test failed: {e}"));
}
// ---------------------------------------------------------------------------
// T M8.1d --- reload of a package without any on_unload hooks works
// ---------------------------------------------------------------------------
#[test]
fn reload_of_package_with_no_on_unload_hooks_succeeds_silently() {
let (_pkg_td, pkg_path) = make_local_pkg_with_init(
"reload-no-hooks",
"return { name = 'reload-no-hooks', marker = 1 }\n",
);
let pkg_str = pkg_path.display().to_string();
let (mut host, _c, _u) = host_with_overrides();
let script = format!(
r#"
pmacs.packages.install_local("{pkg_str}")
local m1 = require("reload-no-hooks")
local m2 = pmacs.packages.reload("reload-no-hooks")
assert(m2.marker == 1, "reload without hooks must re-yield the same module")
"#
);
host.eval(Some("no-hooks"), &script)
.unwrap_or_else(|e| panic!("hookless reload failed: {e}"));
}
// ---------------------------------------------------------------------------
// T M8.1d --- reload of an unknown package name surfaces a clear error
// ---------------------------------------------------------------------------
#[test]
fn reload_of_unknown_package_name_returns_clear_error() {
let (mut host, _c, _u) = host_with_overrides();
let err = host
.eval(Some("unknown"), r#"pmacs.packages.reload("no-such-pkg")"#)
.expect_err("must error on unknown name");
let msg = err.to_string();
assert!(
msg.contains("no-such-pkg"),
"error must name the unknown package: {msg}"
);
assert!(
msg.contains("installed()") || msg.contains("not in"),
"error should point at the roster as the source of truth: {msg}"
);
}
// ---------------------------------------------------------------------------
// T M8.1d --- on_unload called from non-package code errors clearly
// ---------------------------------------------------------------------------
#[test]
fn on_unload_called_outside_a_package_chunk_errors_clearly() {
// Calling on_unload from `init.lua`'s top-level (or any chunk
// that isn't a package's init) has no owning package to attach
// the hook to. The error should name a real alternative API
// (`pmacs.hook.add('editor.before-quit', ...)`) so the user
// knows what to do.
let (mut host, _c, _u) = host_with_overrides();
let err = host
.eval(Some("outside"), r"pmacs.packages.on_unload(function() end)")
.expect_err("must error when called outside a package");
let msg = err.to_string();
assert!(
msg.contains("on_unload") && msg.contains("package"),
"error should explain the constraint: {msg}"
);
assert!(
msg.contains("hook.add") && msg.contains("editor.before-quit"),
"error should point at the actual hook API for non-package teardown: {msg}"
);
}
// ---------------------------------------------------------------------------
// M8.1 H#1 regression --- reload clears the per-package _ENV cache so
// globals removed in source disappear after reload, not just the
// chunk's return value
// ---------------------------------------------------------------------------
#[test]
fn reload_clears_package_env_so_removed_globals_disappear() {
// First load: writes a package-local global `_ENV.SECRET = "v1"`
// (the chunk runs inside a per-package _ENV with __index=_G, so
// bare assignment lands in the env).
// Edit on disk: source no longer sets SECRET.
// After reload, _ENV.SECRET must be nil. Without env-cache
// clearing, the prior chunk's SECRET assignment would persist
// because the env table outlives the chunk that wrote it ---
// exactly the dev-loop trap the M8.1 review caught.
let (_pkg_td, pkg_path) = make_local_pkg_with_init(
"reload-env",
r#"
SECRET = "v1"
return {
read_secret = function() return SECRET end,
}
"#,
);
let pkg_str = pkg_path.display().to_string();
let (mut host, _c, _u) = host_with_overrides();
let warm = format!(
r#"
pmacs.packages.install_local("{pkg_str}")
local m = require("reload-env")
assert(m.read_secret() == "v1", "warm-up must see SECRET = 'v1'")
"#
);
host.eval(Some("warm"), &warm)
.unwrap_or_else(|e| panic!("warm-up failed: {e}"));
// New version of the source: SECRET is gone, and the closure
// reads it via _ENV.SECRET (which should now be nil).
std::fs::write(
pkg_path.join("init.lua"),
r"
return {
read_secret = function() return SECRET end,
}
",
)
.expect("re-write entry");
let after = r#"
local m = pmacs.packages.reload("reload-env")
local got = m.read_secret()
assert(got == nil,
"after reload, SECRET should be nil (env cache cleared); " ..
"got " .. tostring(got))
"#;
host.eval(Some("after"), after)
.unwrap_or_else(|e| panic!("env-cache regression failed: {e}"));
}
// ---------------------------------------------------------------------------
// M8.1 M#2 regression --- on_unload cannot be spoofed by setting
// _PACKAGE in _G or any user-controlled table
// ---------------------------------------------------------------------------
#[test]
fn on_unload_rejects_forged_package_marker_in_global_env() {
// Pre-fix, on_unload trusted callback.environment()._PACKAGE.name.
// Top-level user code could set `_PACKAGE = { name = "victim" }`
// in _G and then call on_unload to inject a hook against the
// victim package. Post-fix, the binding identity-checks the env
// table against the registered per-package envs in
// pmacs.pkgenvs --- _G is never a registered env, so the call
// errors regardless of any forged _PACKAGE field.
let (mut host, _c, _u) = host_with_overrides();
let err = host
.eval(
Some("forge"),
r#"
_PACKAGE = { name = "victim-package" }
pmacs.packages.on_unload(function() end)
"#,
)
.expect_err("forged _PACKAGE must not satisfy on_unload");
let msg = err.to_string();
assert!(
msg.contains("on_unload"),
"error must name on_unload: {msg}"
);
// The forged name must not appear in the error --- if it did,
// the binding had read it (and registered the hook) before
// erroring, which would defeat the security claim.
assert!(
!msg.contains("victim-package"),
"forged _PACKAGE.name must not be readable: {msg}"
);
}
// ---------------------------------------------------------------------------
// M8.1 2pass M#1 regression --- install_local replacement runs
// the prior load's on_unload hooks (and clears them so they don't
// fire alongside the new load's hooks on a later reload)
// ---------------------------------------------------------------------------
#[test]
fn install_local_replacement_runs_old_unload_hooks_then_clears_them() {
let (a_td, _a_path) = make_local_pkg_with_init(
"swap-hooks",
r"
_G.SWAP_LOG = _G.SWAP_LOG or {}
pmacs.packages.on_unload(function()
table.insert(_G.SWAP_LOG, 'A-unload')
end)
return { name = 'swap-hooks', version = 'A' }
",
);
let a_str = a_td.path().display().to_string();
let (b_td, _b_path) = make_local_pkg_with_init(
"swap-hooks",
r"
_G.SWAP_LOG = _G.SWAP_LOG or {}
pmacs.packages.on_unload(function()
table.insert(_G.SWAP_LOG, 'B-unload')
end)
return { name = 'swap-hooks', version = 'B' }
",
);
let b_str = b_td.path().display().to_string();
let (mut host, _c, _u) = host_with_overrides();
let script = format!(
r#"
pmacs.packages.install_local("{a_str}")
local a = require("swap-hooks")
assert(a.version == 'A', "first load yields A")
assert(#_G.SWAP_LOG == 0, "no hooks fired yet")
-- Replace A with B at the same basename. install_local
-- should fire A's hooks before swapping in B; B's chunk
-- hasn't run yet so it has no hooks of its own at this
-- point.
pmacs.packages.install_local("{b_str}")
assert(#_G.SWAP_LOG == 1, "A's hook should fire on swap; got " .. #_G.SWAP_LOG)
assert(_G.SWAP_LOG[1] == 'A-unload')
-- Loading B registers a fresh hook against the B chunk's env.
local b = require("swap-hooks")
assert(b.version == 'B', "post-swap require yields B")
-- Reload should run *only* B's hook --- A's hook list was
-- drained at install_local time. Pre-fix, both A's and B's
-- hooks would be in the registry and both would fire.
pmacs.packages.reload("swap-hooks")
assert(#_G.SWAP_LOG == 2,
"reload after swap should fire only B's hook; got " .. #_G.SWAP_LOG)
assert(_G.SWAP_LOG[2] == 'B-unload',
"second log entry should be B-unload, got " .. tostring(_G.SWAP_LOG[2]))
"#
);
host.eval(Some("swap"), &script)
.unwrap_or_else(|e| panic!("install_local hook handoff failed: {e}"));
}
// ---------------------------------------------------------------------------
// M8.1 3pass M#2 regression --- a failing on_unload hook stays at
// the front of the registry so retry re-attempts the same cleanup
// ---------------------------------------------------------------------------
#[test]
fn reload_failing_hook_is_retried_until_it_succeeds() {
// Hooks must be idempotent: a hook that fails on cycle 1 will
// be re-attempted on cycle 2 (because the cleanup it represents
// didn't complete). Only a successful call pops the hook from
// the registry.
//
// Pre-fix the popped-then-called shape silently dropped the
// failing hook, so cycle 2 jumped to hook 3 (skipping the
// cleanup that actually failed). That meant a buggy
// `worker:stop()` would never get retried; the package would
// claim to have unloaded but leave its worker running.
let (_pkg_td, pkg_path) = make_local_pkg_with_init(
"reload-retry",
r"
_G.RETRY_LOG = _G.RETRY_LOG or {}
_G.MIDDLE_HOOK_SHOULD_FAIL = (_G.MIDDLE_HOOK_SHOULD_FAIL == nil) and true or _G.MIDDLE_HOOK_SHOULD_FAIL
pmacs.packages.on_unload(function()
table.insert(_G.RETRY_LOG, '1')
end)
pmacs.packages.on_unload(function()
table.insert(_G.RETRY_LOG, '2-attempt')
if _G.MIDDLE_HOOK_SHOULD_FAIL then
error('middle hook intentional fail')
end
end)
pmacs.packages.on_unload(function()
table.insert(_G.RETRY_LOG, '3')
end)
return { name = 'reload-retry' }
",
);
let pkg_str = pkg_path.display().to_string();
let (mut host, _c, _u) = host_with_overrides();
// First reload: hook 1 runs (popped on success). Hook 2
// errors --- it stays at the front of the registry. Hook 3
// doesn't run.
let first = format!(
r#"
pmacs.packages.install_local("{pkg_str}")
require("reload-retry")
local ok, err = pcall(pmacs.packages.reload, "reload-retry")
assert(not ok, "first reload must fail because hook 2 errors")
assert(#_G.RETRY_LOG == 2,
"after first reload, exactly 2 entries (1 + 2-attempt); got " .. #_G.RETRY_LOG)
assert(_G.RETRY_LOG[1] == '1')
assert(_G.RETRY_LOG[2] == '2-attempt')
"#
);
host.eval(Some("first"), &first)
.unwrap_or_else(|e| panic!("first reload setup failed: {e}"));
// Second reload, with the failure-flag cleared. Hook 2 (still
// at the front) is RE-ATTEMPTED, this time succeeding. Hook 3
// then runs. Hook 1 is NOT re-run (it succeeded on cycle 1
// and was popped). The post-pop re-require registers fresh
// 1/2/3 hooks against the new chunk but they don't run yet.
let second = r#"
_G.MIDDLE_HOOK_SHOULD_FAIL = false
local ok = pcall(pmacs.packages.reload, "reload-retry")
assert(ok, "second reload should succeed")
-- Expected log delta: '2-attempt' (retry succeeds) then '3'.
assert(#_G.RETRY_LOG == 4,
"second reload should re-run hook 2 then run hook 3; got " ..
#_G.RETRY_LOG ..
" entries: " .. table.concat(_G.RETRY_LOG, ","))
assert(_G.RETRY_LOG[3] == '2-attempt',
"hook 2 must be retried, not skipped; got " .. _G.RETRY_LOG[3])
assert(_G.RETRY_LOG[4] == '3',
"hook 3 must run after hook 2 retry succeeds; got " .. _G.RETRY_LOG[4])
"#;
host.eval(Some("second"), second)
.unwrap_or_else(|e| panic!("retry reload failed: {e}"));
}
// ---------------------------------------------------------------------------
// M8.1 4pass M#1 regression --- on_unload registrations during a
// cycle land in the next cycle, not the current one
// ---------------------------------------------------------------------------
#[test]
fn on_unload_registered_during_cycle_does_not_run_in_same_cycle() {
// Pre-fix the cycle ran from the live registry; a hook that
// called `on_unload(...)` from inside its body would extend
// the current cycle. A self-replicating hook would loop
// reload() forever.
//
// Post-fix the cycle runs from a snapshot drained at start;
// new registrations land in the empty live registry slot.
// Then `clear_package_env` drops them at end-of-cycle (they
// hold the now-discarded chunk's env), and the freshly-
// re-required chunk registers its own clean hooks for the
// next cycle.
//
// Net effect: a self-replicating hook fires exactly once per
// reload (the just-required chunk's registration), and the
// cycle returns instead of hanging.
let (_pkg_td, pkg_path) = make_local_pkg_with_init(
"reload-self-replicate",
r"
_G.SELF_REPLICATE_LOG = _G.SELF_REPLICATE_LOG or {}
local function clean_up()
table.insert(_G.SELF_REPLICATE_LOG, 'tick')
-- Re-register *this same closure*. A live-queue
-- runner would loop forever here; a snapshot
-- runner runs the queued hook once and lets the
-- self-registration land in the registry for the
-- next cycle (where it'll be discarded with the
-- old env).
pmacs.packages.on_unload(clean_up)
end
pmacs.packages.on_unload(clean_up)
return { name = 'reload-self-replicate' }
",
);
let pkg_str = pkg_path.display().to_string();
let (mut host, _c, _u) = host_with_overrides();
let script = format!(
r#"
pmacs.packages.install_local("{pkg_str}")
require("reload-self-replicate")
-- Reload 1: snapshot is [clean_up], queue runs once,
-- clean_up's body re-registers clean_up. clear_package_env
-- then drops that surviving hook (its env is the now-
-- discarded old env). require re-runs the chunk against
-- the new env, registering a fresh clean_up.
pmacs.packages.reload("reload-self-replicate")
assert(#_G.SELF_REPLICATE_LOG == 1,
"first reload must fire exactly once; got " .. #_G.SELF_REPLICATE_LOG)
-- Reload 2: same shape. Fresh chunk's hook fires; the
-- self-registered copy is dropped at clear time.
pmacs.packages.reload("reload-self-replicate")
assert(#_G.SELF_REPLICATE_LOG == 2,
"second reload should fire exactly once more; got " ..
#_G.SELF_REPLICATE_LOG)
-- Reload 3 confirms the steady state.
pmacs.packages.reload("reload-self-replicate")
assert(#_G.SELF_REPLICATE_LOG == 3,
"third reload should fire exactly once more; got " ..
#_G.SELF_REPLICATE_LOG)
-- Most importantly: every reload returned. A live-queue
-- runner would have hung the test forever on reload 1.
"#
);
host.eval(Some("self-replicate"), &script)
.unwrap_or_else(|e| panic!("self-replicating-hook test failed: {e}"));
}
// ---------------------------------------------------------------------------
// M8.1 3pass H#1 regression --- install_local with a failing
// on_unload hook leaves the disk symlink unchanged
// ---------------------------------------------------------------------------
#[test]
fn install_local_failing_hook_leaves_symlink_unchanged() {
// Install A, register an on_unload hook that always errors.
// Try to install_local B at the same name. The hook fires
// BEFORE the symlink swap (per the H#1 plan/commit split), so
// its failure aborts install_local with disk untouched: the
// symlink still points at A, the roster still tracks A,
// require("name") still loads A, and the failing hook stays in
// the registry for retry.
let (a_td, _a_path) = make_local_pkg_with_init(
"swap-fail",
r"
_G.SWAPFAIL_LOG = _G.SWAPFAIL_LOG or {}
_G.SWAPFAIL_HOOK_FAILS = (_G.SWAPFAIL_HOOK_FAILS == nil) and true or _G.SWAPFAIL_HOOK_FAILS
pmacs.packages.on_unload(function()
table.insert(_G.SWAPFAIL_LOG, 'A-attempt')
if _G.SWAPFAIL_HOOK_FAILS then
error('A unload intentional fail')
end
end)
return { name = 'swap-fail', version = 'A' }
",
);
let a_str = a_td.path().display().to_string();
let (b_td, _b_path) = make_local_pkg_with_init(
"swap-fail",
r"
return { name = 'swap-fail', version = 'B' }
",
);
let b_str = b_td.path().display().to_string();
let (mut host, _c, user_root) = host_with_overrides();
// Install A and require it (registers the failing hook).
let setup = format!(
r#"
pmacs.packages.install_local("{a_str}")
local a = require("swap-fail")
assert(a.version == 'A')
"#
);
host.eval(Some("setup"), &setup)
.unwrap_or_else(|e| panic!("setup failed: {e}"));
let a_target = std::fs::read_link(user_root.path().join("swap-fail"))
.expect("read symlink before swap attempt");
// Try install_local B: must error because A's on_unload fails.
let attempt = format!(r#"pmacs.packages.install_local("{b_str}")"#);
let err = host
.eval(Some("attempt"), &attempt)
.expect_err("install_local must fail when A's unload hook errors");
let msg = err.to_string();
assert!(
msg.contains("A unload intentional fail") || msg.contains("intentional fail"),
"error must surface the failing hook's message; got {msg}"
);
// Disk is unchanged: the symlink still points at A's source.
let after_target = std::fs::read_link(user_root.path().join("swap-fail"))
.expect("read symlink after failed swap");
assert_eq!(
a_target, after_target,
"symlink target must be unchanged after a failed install_local"
);
assert!(
!user_root.path().join(".swap-fail.swap.tmp").exists(),
"failed install_local must clean up the staged swap symlink"
);
// require still returns A (the existing module is cached).
host.eval(
Some("require_after"),
r"
local m = require('swap-fail')
assert(m.version == 'A',
'after failed swap, require must still return A; got ' .. tostring(m.version))
",
)
.unwrap_or_else(|e| panic!("post-failure require check failed: {e}"));
// Now disable the hook failure and retry install_local B. The
// hook re-attempts and succeeds; B is installed, require
// returns B.
let recovery = format!(
r#"
_G.SWAPFAIL_HOOK_FAILS = false
pmacs.packages.install_local("{b_str}")
local b = require('swap-fail')
assert(b.version == 'B',
'after recovery, require must return B; got ' .. tostring(b.version))
-- Hook A-attempt should appear twice in the log: once for
-- the failed first attempt, once for the successful retry.
assert(#_G.SWAPFAIL_LOG == 2,
'A-attempt should appear twice; got ' .. #_G.SWAPFAIL_LOG ..
' entries: ' .. table.concat(_G.SWAPFAIL_LOG, ','))
"#
);
host.eval(Some("recovery"), &recovery)
.unwrap_or_else(|e| panic!("recovery install_local failed: {e}"));
}
// ---------------------------------------------------------------------------
// T M8.1d --- reloadable packages can define commands and clean them up
// ---------------------------------------------------------------------------
//
// Regression for the M8.2 close-out finding: a package that defines
// commands at top level cannot be reloaded if it doesn't unregister
// them in its unload hook (the second chunk run hits DuplicateName).
// This test pins the contract end-to-end:
//
// 1. install_local a package that defines a command and registers
// an on_unload hook to unregister it.
// 2. invoke the command, observe v1 behavior.
// 3. edit the package's command body on disk.
// 4. reload the package.
// 5. invoke the command, observe v2 behavior --- proving both that
// the unload hook successfully cleared the slot and that re-define
// succeeded.
#[test]
fn reload_works_for_package_that_defines_and_unregisters_commands() {
let init_v1 = r#"
local M = {}
pmacs.command.define {
name = "rwc.greet",
description = "Greet (v1).",
fn = function() return "hello-v1" end,
}
pmacs.packages.on_unload(function()
pmacs.command.unregister("rwc.greet")
end)
return M
"#;
let (_pkg_td, pkg_path) = make_local_pkg_with_init("reload-with-commands", init_v1);
let pkg_str = pkg_path.display().to_string();
let (mut host, _c, _u) = host_with_overrides();
let warm = format!(
r#"
pmacs.packages.install_local("{pkg_str}")
require("reload-with-commands")
local r = pmacs.command.invoke("rwc.greet")
assert(r == "hello-v1", "v1 command should return 'hello-v1'; got " .. tostring(r))
"#
);
host.eval(Some("warm-rwc"), &warm)
.unwrap_or_else(|e| panic!("warm-up failed: {e}"));
// Edit the package on disk: change command body.
let init_v2 = r#"
local M = {}
pmacs.command.define {
name = "rwc.greet",
description = "Greet (v2).",
fn = function() return "hello-v2" end,
}
pmacs.packages.on_unload(function()
pmacs.command.unregister("rwc.greet")
end)
return M
"#;
std::fs::write(pkg_path.join("init.lua"), init_v2).expect("rewrite init.lua");
let reload_chunk = r#"
pmacs.packages.reload("reload-with-commands")
local r = pmacs.command.invoke("rwc.greet")
assert(r == "hello-v2",
"after reload, command must return 'hello-v2'; got " .. tostring(r))
-- describe.command should also pick up the new description.
local info = pmacs.describe.command("rwc.greet")
assert(info.description == "Greet (v2).",
"describe.command must reflect the v2 description; got " ..
tostring(info.description))
"#;
host.eval(Some("reload-rwc"), reload_chunk)
.unwrap_or_else(|e| panic!("reload + invoke v2 failed: {e}"));
}
// Companion regression: post-init reload must work when a package's
// on_unload hook calls pmacs.command.unregister. The earlier
// command-defining reload test runs in init phase (LuaHost::new()
// leaves init open), which would mask an init-gated unregister.
// This test flips init-complete after install_local --- the same
// state the editor reaches at runtime --- so a future re-introduction
// of the gate would fail here.
#[test]
fn reload_after_init_complete_runs_unregister_in_unload_hook() {
let init_v1 = r#"
pmacs.command.define {
name = "rwc-postinit.greet",
description = "Greet (v1).",
fn = function() return "post-v1" end,
}
pmacs.packages.on_unload(function()
pmacs.command.unregister("rwc-postinit.greet")
end)
return {}
"#;
let (_pkg_td, pkg_path) = make_local_pkg_with_init("reload-postinit", init_v1);
let pkg_str = pkg_path.display().to_string();
let (mut host, _c, _u) = host_with_overrides();
// Install during init phase (the editor's startup window).
host.eval(
Some("warm-postinit"),
&format!(
r#"
pmacs.packages.install_local("{pkg_str}")
require("reload-postinit")
"#
),
)
.unwrap_or_else(|e| panic!("warm-up failed: {e}"));
// Flip the init flag the way EditorState's startup sequence
// does. From here on, install_local and other init-only APIs
// would refuse --- but reload should still work, and the
// unload hook's unregister call must succeed in this state.
host.set_init_complete();
// Edit on disk and reload from the post-init editor.
let init_v2 = r#"
pmacs.command.define {
name = "rwc-postinit.greet",
description = "Greet (v2).",
fn = function() return "post-v2" end,
}
pmacs.packages.on_unload(function()
pmacs.command.unregister("rwc-postinit.greet")
end)
return {}
"#;
std::fs::write(pkg_path.join("init.lua"), init_v2).expect("rewrite init.lua");
host.eval(
Some("postinit-reload"),
r#"
pmacs.packages.reload("reload-postinit")
local r = pmacs.command.invoke("rwc-postinit.greet")
assert(r == "post-v2",
"post-init reload must update command body; got " .. tostring(r))
"#,
)
.unwrap_or_else(|e| panic!("post-init reload failed: {e}"));
}
// Companion test: a package that *forgets* to unregister its
// commands hits DuplicateName on reload, with an actionable error.
// This is the failure mode the regression test above guards against.
#[test]
fn reload_of_command_defining_package_without_cleanup_errors_clearly() {
let init_no_cleanup = r#"
pmacs.command.define {
name = "rwc-leak.greet",
description = "Greet without cleanup.",
fn = function() return "v1" end,
}
return {}
"#;
let (_pkg_td, pkg_path) = make_local_pkg_with_init("reload-leak", init_no_cleanup);
let pkg_str = pkg_path.display().to_string();
let (mut host, _c, _u) = host_with_overrides();
host.eval(
Some("warm-leak"),
&format!(
r#"
pmacs.packages.install_local("{pkg_str}")
require("reload-leak")
"#
),
)
.unwrap_or_else(|e| panic!("install + require failed: {e}"));
// No edit needed --- the second chunk run will collide.
let err = host
.eval(
Some("reload-leak"),
r#"pmacs.packages.reload("reload-leak")"#,
)
.expect_err("reload of command-leaking package must fail");
let msg = err.to_string();
assert!(
msg.contains("rwc-leak.greet") && msg.contains("already defined"),
"error must point at the duplicate command: {msg}"
);
}