pmacs/tests/m7_3_acceptance.rs

598 lines
20 KiB
Rust

// m7_3_acceptance.rs --- Acceptance suite for T M7.3 (`pmacs.packages.*`).
//! End-to-end acceptance tests for T M7.3 (`pmacs.packages.install`,
//! `pmacs.packages.install_project`).
//!
//! The three acceptance bullets from the task definition:
//!
//! 1. **User-config install of a sample package succeeds and the
//! package's entry module is requireable.** Tested here as
//! [`user_install_makes_entry_requireable`]. The test stages a
//! bare git repo containing a `pmacs.toml` + `init.lua`, redirects
//! the install machinery away from the developer's real
//! `$XDG_*` paths via [`PackageInstallOverride`], calls
//! `pmacs.packages.install`, and verifies that
//! `require("samplepkg")` returns the entry module's table.
//! 2. **Project install for a sample package isolates from
//! user-config.** Tested as
//! [`project_install_isolates_from_user_install`]. A user install
//! and a project install of the same upstream land in different
//! on-disk roots; both are listed in `pmacs.packages.installed()`.
//! 3. **Both variants are documented at the public Lua API surface
//! with `EmmyLua`-style annotations.** Verified by the static
//! [`emmylua_doc_file_exists`] check, which reads
//! `builtin/api/packages.lua` and confirms it contains `EmmyLua`
//! annotations for `install` and `install_project`.
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Command;
use pmacs::lua::LuaHost;
use pmacs::lua_bindings::PackageInstallOverride;
use tempfile::TempDir;
// ---------------------------------------------------------------------------
// Sample-package fixture
// ---------------------------------------------------------------------------
/// Build a sample-package bare repo whose entry is at `entry_path`
/// (relative to the package root) with the supplied Lua body.
/// Returns `(tempdir, bare_path)` --- the tempdir owns both the work
/// tree and the bare clone.
fn make_package_with_entry(
name: &str,
entry_path: &str,
entry_body: &str,
) -> (TempDir, PathBuf) {
let td = tempfile::tempdir().expect("tempdir");
let work = td.path().join("work");
let bare = td.path().join("upstream.git");
run_git(&[
OsStr::new("init"),
OsStr::new("--initial-branch=main"),
work.as_os_str(),
]);
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("config"),
OsStr::new("user.email"),
OsStr::new("test@example.com"),
]);
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("config"),
OsStr::new("user.name"),
OsStr::new("Tester"),
]);
let manifest = format!(
"name = \"{name}\"\n\
version = \"1.0.0\"\n\
summary = \"acceptance fixture\"\n\
pmacs_required = \">= 0.1.0\"\n\
entry = \"{entry_path}\"\n\
exports = [\"{name}\"]\n"
);
std::fs::write(work.join("pmacs.toml"), manifest).expect("write pmacs.toml");
let entry_full = work.join(entry_path);
if let Some(parent) = entry_full.parent() {
std::fs::create_dir_all(parent).expect("mkdir entry parent");
}
std::fs::write(&entry_full, entry_body).expect("write entry");
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("add"),
OsStr::new("."),
]);
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("commit"),
OsStr::new("-m"),
OsStr::new("v1.0.0"),
]);
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("tag"),
OsStr::new("v1.0.0"),
]);
run_git(&[
OsStr::new("clone"),
OsStr::new("--bare"),
work.as_os_str(),
bare.as_os_str(),
]);
(td, bare)
}
/// Build a sample-package bare repo with a `pmacs.toml` + `init.lua`,
/// tagged `v1.0.0`. Returns `(tempdir, bare_path)`.
fn make_sample_package(name: &str) -> (TempDir, PathBuf) {
let td = tempfile::tempdir().expect("tempdir");
let work = td.path().join("work");
let bare = td.path().join("upstream.git");
run_git(&[
OsStr::new("init"),
OsStr::new("--initial-branch=main"),
work.as_os_str(),
]);
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("config"),
OsStr::new("user.email"),
OsStr::new("test@example.com"),
]);
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("config"),
OsStr::new("user.name"),
OsStr::new("Tester"),
]);
let manifest = format!(
"name = \"{name}\"\n\
version = \"1.0.0\"\n\
summary = \"acceptance fixture\"\n\
pmacs_required = \">= 0.1.0\"\n\
entry = \"init.lua\"\n\
exports = [\"{name}\"]\n"
);
std::fs::write(work.join("pmacs.toml"), manifest).expect("write pmacs.toml");
std::fs::write(
work.join("init.lua"),
format!("return {{ name = '{name}', version = '1.0.0' }}\n"),
)
.expect("write init.lua");
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("add"),
OsStr::new("."),
]);
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("commit"),
OsStr::new("-m"),
OsStr::new("v1.0.0"),
]);
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("tag"),
OsStr::new("v1.0.0"),
]);
run_git(&[
OsStr::new("clone"),
OsStr::new("--bare"),
work.as_os_str(),
bare.as_os_str(),
]);
(td, bare)
}
fn run_git(args: &[&OsStr]) {
let mut cmd = Command::new("git");
for a in args {
cmd.arg(a);
}
cmd.env("GIT_TERMINAL_PROMPT", "0");
cmd.env("LC_ALL", "C");
let out = cmd.output().expect("git spawn");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn file_url(p: &Path) -> String {
format!("file://{}", p.display())
}
/// Build a [`LuaHost`] with the package-install machinery redirected
/// to per-test tempdirs. Returns the host plus the paths so the test
/// can introspect what landed where.
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)
}
// ---------------------------------------------------------------------------
// Acceptance bullet 1: user-config install + entry requireable.
// ---------------------------------------------------------------------------
#[test]
fn user_install_makes_entry_requireable() {
let (_pkg_td, bare) = make_sample_package("samplepkg");
let url = file_url(&bare);
let (mut host, _cache, _user_root) = host_with_overrides();
let script = format!(
r#"
local installed = pmacs.packages.install {{
"git:{url}",
version = "^1.0.0",
}}
assert(installed.name == "samplepkg",
"expected name == samplepkg, got " .. tostring(installed.name))
assert(installed.version == "1.0.0",
"expected version 1.0.0, got " .. tostring(installed.version))
assert(installed.scope == "user",
"expected scope user, got " .. tostring(installed.scope))
-- The acceptance bullet: the entry module is requireable.
local mod = require("samplepkg")
assert(mod.name == "samplepkg",
"require returned wrong table: " .. tostring(mod.name))
assert(mod.version == "1.0.0",
"require returned wrong version: " .. tostring(mod.version))
-- The roster reflects the install.
local list = pmacs.packages.installed()
assert(#list == 1, "expected 1 installed package, got " .. tostring(#list))
assert(list[1].name == "samplepkg")
"#,
);
host.eval(Some("test"), &script).unwrap_or_else(|e| {
panic!("install + require failed: {e}");
});
assert!(
host.errors().is_empty(),
"errors after install: {:?}",
host.errors()
);
}
// ---------------------------------------------------------------------------
// Acceptance bullet 2: project install isolates from user install.
// ---------------------------------------------------------------------------
#[test]
fn project_install_isolates_from_user_install() {
let (_pkg_td, bare) = make_sample_package("samplepkg");
let url = file_url(&bare);
let (mut host, _cache, user_root) = host_with_overrides();
// The project root is its own tempdir, distinct from the user root.
let project = tempfile::tempdir().expect("project tempdir");
let project_root = project.path().display().to_string();
let user_root_str = user_root.path().display().to_string();
let script = format!(
r#"
local user = pmacs.packages.install {{
"git:{url}",
version = "^1.0.0",
}}
local proj = pmacs.packages.install_project {{
"git:{url}",
version = "^1.0.0",
project_root = "{project_root}",
}}
assert(user.scope == "user", "user scope")
assert(proj.scope == "project", "project scope")
assert(user.install_path ~= proj.install_path,
"install paths must differ: " .. user.install_path .. " vs " .. proj.install_path)
assert(string.find(proj.install_path, "{project_root}", 1, true) ~= nil,
"project path should be under project_root: " .. proj.install_path)
assert(string.find(user.install_path, "{user_root_str}", 1, true) ~= nil,
"user path should be under user_root: " .. user.install_path)
-- Both records present.
local list = pmacs.packages.installed()
assert(#list == 2, "expected 2, got " .. tostring(#list))
"#,
);
host.eval(Some("test"), &script).unwrap_or_else(|e| {
panic!("install isolation failed: {e}");
});
assert!(
host.errors().is_empty(),
"errors after dual install: {:?}",
host.errors()
);
}
// ---------------------------------------------------------------------------
// Acceptance bullet 3: EmmyLua annotations exist.
// ---------------------------------------------------------------------------
#[test]
fn emmylua_doc_file_exists() {
// The acceptance bullet says "documented at the public Lua API
// surface with `EmmyLua`-style annotations". This test pins that
// documentation file so it stays in lockstep with the binding.
let p = Path::new(env!("CARGO_MANIFEST_DIR")).join("builtin/api/packages.lua");
let s = std::fs::read_to_string(&p)
.unwrap_or_else(|e| panic!("missing EmmyLua doc file at {}: {e}", p.display()));
assert!(
s.contains("@param"),
"EmmyLua doc file must contain @param annotations"
);
assert!(s.contains("install"), "doc file must document `install`");
assert!(
s.contains("install_project"),
"doc file must document `install_project`"
);
}
// ---------------------------------------------------------------------------
// Init-time-only gate.
// ---------------------------------------------------------------------------
#[test]
fn install_after_init_complete_errors_with_workaround() {
let mut host = LuaHost::new().expect("LuaHost::new");
host.set_init_complete();
let err = host
.eval(
Some("test"),
r#"pmacs.packages.install { "github:user/repo", version = "*" }"#,
)
.expect_err("post-init install must error");
let msg = err.to_string();
assert!(
msg.contains("pmacs.packages.install"),
"error must name the op: {msg}"
);
assert!(
msg.contains("init.lua"),
"error must name the right phase: {msg}"
);
}
// ---------------------------------------------------------------------------
// Spec-shape parsing.
// ---------------------------------------------------------------------------
#[test]
fn shorthand_string_form_is_accepted() {
let (_pkg_td, bare) = make_sample_package("samplepkg");
let url = file_url(&bare);
let (mut host, _cache, _user_root) = host_with_overrides();
let script = format!(
r#"
local installed = pmacs.packages.install("git:{url}@^1.0.0")
assert(installed.name == "samplepkg")
"#,
);
host.eval(Some("test"), &script).unwrap_or_else(|e| {
panic!("shorthand form failed: {e}");
});
}
#[test]
fn install_spec_missing_address_errors() {
let mut host = LuaHost::new().expect("LuaHost::new");
let err = host
.eval(Some("test"), r#"pmacs.packages.install { version = "*" }"#)
.expect_err("missing address must error");
let msg = err.to_string();
assert!(
msg.contains("address"),
"error should name the missing field: {msg}"
);
}
// ---------------------------------------------------------------------------
// Reviewer-flagged item 10: install_project requires explicit project_root.
// ---------------------------------------------------------------------------
#[test]
fn install_project_without_project_root_errors_with_workaround() {
// Pre-v0.1 the missing field silently fell back to
// `std::env::current_dir()`. That was a footgun: CWD-at-startup
// is whatever shell directory the user happened to invoke pmacs
// from, almost never a meaningful project root. The fallback is
// gone; the binding now requires an explicit field and the error
// message names two concrete patterns for filling it in
// (env-var lookup, init.lua-relative path).
let mut host = LuaHost::new().expect("LuaHost::new");
let err = host
.eval(
Some("test"),
r#"pmacs.packages.install_project { "git:does-not-matter", version = "^1.0" }"#,
)
.expect_err("missing project_root must error");
let msg = err.to_string();
assert!(
msg.contains("project_root"),
"error must name the missing field: {msg}"
);
assert!(
msg.contains("install_project"),
"error must name the op: {msg}"
);
// Two concrete patterns the user can apply without hunting for
// documentation. Both are mentioned in the error text so a CI
// log line stands on its own.
assert!(
msg.contains("PMACS_PROJECT") || msg.contains("os.getenv"),
"error should hint at env-var pattern: {msg}"
);
assert!(
msg.contains("init.lua"),
"error should hint at init.lua-relative pattern: {msg}"
);
}
#[test]
fn install_project_relative_path_resolves_against_init_lua_dir() {
// A relative `project_root` value resolves against the
// directory of the loading chunk (the convention for
// file-loaded `init.lua`). The chunk source label
// `@<absolute-path>` is the standard hook --- `LuaHost::eval`
// sets it via `set_name`, and `debug.getinfo("S").source`
// reads it back.
//
// Setup: write a transient `init.lua` under tempdir-A, set
// `project_root = "subproj"` from inside it (a relative path),
// and assert the install lands at `tempdir-A/subproj/.pmacs/...`.
let (_pkg_td, bare) = make_sample_package("samplepkg");
let url = file_url(&bare);
let (mut host, _cache, _user_root) = host_with_overrides();
let init_dir = tempfile::tempdir().expect("init dir");
let init_path = init_dir.path().join("init.lua");
let init_label = format!("@{}", init_path.display());
let script = format!(
r#"
local p = pmacs.packages.install_project {{
"git:{url}",
version = "^1.0.0",
project_root = "subproj",
}}
return p.install_path
"#,
);
let value = host
.eval(Some(&init_label), &script)
.unwrap_or_else(|e| panic!("relative project_root install failed: {e}"));
let install_path = match value {
mlua::Value::String(s) => s.to_str().expect("string utf8").to_string(),
other => panic!("expected install_path string, got {other:?}"),
};
let expected_prefix = init_dir.path().join("subproj");
assert!(
install_path.starts_with(&expected_prefix.display().to_string()),
"expected install under {expected_prefix:?}, got {install_path:?}"
);
}
// ---------------------------------------------------------------------------
// Reviewer-flagged item 7: custom entry paths must be requireable.
// ---------------------------------------------------------------------------
//
// A package whose manifest declares `entry = "main.lua"` (or any
// non-`init.lua` path) cannot be loaded via the standard
// `?.lua;?/init.lua` `package.path` pattern alone --- the path
// search misses the entry file. The custom searcher in
// `lua_bindings::register_package_searcher` closes the gap by
// mapping `require("<basename>")` directly to the manifest's
// declared entry path.
#[test]
fn package_with_main_lua_entry_is_requireable() {
// Entry file at `main.lua` (not the conventional `init.lua`).
// The path-based searcher misses it; the custom searcher must
// route `require("samplepkg")` to `<install>/main.lua`.
let (_pkg_td, bare) = make_package_with_entry(
"samplepkg",
"main.lua",
"return { name = 'samplepkg', via = 'main.lua' }\n",
);
let url = file_url(&bare);
let (mut host, _cache, _user_root) = host_with_overrides();
let script = format!(
r#"
local installed = pmacs.packages.install {{
"git:{url}",
version = "^1.0.0",
}}
assert(installed.entry:sub(-#"main.lua") == "main.lua",
"manifest entry must be main.lua, got " .. installed.entry)
local mod = require("samplepkg")
assert(mod.name == "samplepkg",
"custom searcher should have loaded main.lua, got: " .. tostring(mod.name))
assert(mod.via == "main.lua",
"module body must be the contents of main.lua, got via=" .. tostring(mod.via))
"#
);
host.eval(Some("test"), &script).unwrap_or_else(|e| {
panic!("require(samplepkg) with entry=main.lua failed: {e}");
});
}
#[test]
fn package_with_nested_entry_path_is_requireable() {
// Entry at `lib/foo.lua` --- a package layout where the
// user-facing module lives a couple of levels deep. The custom
// searcher must read it from the manifest and resolve
// `require("samplepkg")` to the nested file.
let (_pkg_td, bare) = make_package_with_entry(
"samplepkg",
"lib/foo.lua",
"return { name = 'samplepkg', via = 'lib/foo.lua' }\n",
);
let url = file_url(&bare);
let (mut host, _cache, _user_root) = host_with_overrides();
let script = format!(
r#"
local installed = pmacs.packages.install {{
"git:{url}",
version = "^1.0.0",
}}
assert(installed.entry:sub(-#"lib/foo.lua") == "lib/foo.lua",
"manifest entry must be lib/foo.lua, got " .. installed.entry)
local mod = require("samplepkg")
assert(mod.via == "lib/foo.lua",
"module body must be the contents of lib/foo.lua, got via=" .. tostring(mod.via))
"#
);
host.eval(Some("test"), &script).unwrap_or_else(|e| {
panic!("require(samplepkg) with nested entry failed: {e}");
});
}
#[test]
fn searcher_misses_for_unknown_name_with_pmacs_specific_message() {
// No installed package matches `unrelatedpkg`. The custom
// searcher returns its "no installed pmacs package named ..."
// string, which Lua appends to the standard require-failure
// chain. The point: a `require` that fails with an obvious
// typo produces an error message that names the pmacs-side
// searcher's contribution to the search, so a user can see
// why the install they thought they did did not satisfy this
// require.
let mut host = LuaHost::new().expect("LuaHost::new");
let err = host
.eval(
Some("test"),
r#"
local mod = require("unrelatedpkg")
return mod
"#,
)
.expect_err("require for unknown name must error");
let msg = err.to_string();
assert!(
msg.contains("unrelatedpkg"),
"error must echo the require name: {msg}"
);
assert!(
msg.contains("no installed pmacs package"),
"error must mention the pmacs searcher's contribution: {msg}"
);
}