--- A tiny, dependency-free busted-compatible test runner (tech-spec §9 sanctions --- "drive nvim ... from the test runner"). Provides the `describe`/`it`/ --- `before_each`/`after_each` globals and a luassert-style `assert` table, then --- runs `*_spec.lua` files in headless Neovim with proper exit codes — no --- external plugins, no network, nothing vendored. local M = {} -- Registration state (module-local; the installed globals close over these). local cases = {} local scope_stack = {} local function full_name(leaf) local parts = {} for _, s in ipairs(scope_stack) do if s.name then parts[#parts + 1] = s.name end end parts[#parts + 1] = leaf return table.concat(parts, " ") end --- Install the spec globals. `assert` becomes a callable luassert-ish table: --- `assert(cond, msg)` still works (so harness code using the builtin is fine), --- plus `assert.are.equal`, `assert.is_true/is_false/is_truthy/is_falsy`. function M.install_globals() _G.describe = function(name, fn) table.insert(scope_stack, { name = name, befores = {}, afters = {} }) fn() table.remove(scope_stack) end _G.before_each = function(fn) table.insert(scope_stack[#scope_stack].befores, fn) end _G.after_each = function(fn) table.insert(scope_stack[#scope_stack].afters, fn) end _G.it = function(name, fn) -- Snapshot the active before/after chain (outermost-first for befores, -- innermost-first for afters), as busted runs them per-test. local befores, afters = {}, {} for _, s in ipairs(scope_stack) do for _, b in ipairs(s.befores) do befores[#befores + 1] = b end end for i = #scope_stack, 1, -1 do for _, a in ipairs(scope_stack[i].afters) do afters[#afters + 1] = a end end table.insert(cases, { name = full_name(name), fn = fn, befores = befores, afters = afters }) end local function fail(msg, level) error(msg, (level or 1) + 1) end local A = setmetatable({}, { __call = function(_, cond, msg) if not cond then fail(msg or "assertion failed") end return cond end, }) local function eq(a, b, msg) if a ~= b then fail(msg or string.format("expected %s, got %s", vim.inspect(b), vim.inspect(a))) end end A.are = { equal = eq, equals = eq } A.equals = eq A.equal = eq A.is_true = function(x, msg) if x ~= true then fail(msg or ("expected true, got " .. vim.inspect(x))) end end A.is_false = function(x, msg) if x ~= false then fail(msg or ("expected false, got " .. vim.inspect(x))) end end A.is_truthy = function(x, msg) if not x then fail(msg or ("expected truthy, got " .. vim.inspect(x))) end end A.is_falsy = function(x, msg) if x then fail(msg or ("expected falsy, got " .. vim.inspect(x))) end end _G.assert = A end --- Run each spec file, returning the number of failed tests. Prints TAP-ish --- lines so failures are obvious in CI logs. function M.run_files(files) local passed, failed = 0, 0 for _, file in ipairs(files) do cases = {} scope_stack = {} local loaded, lerr = pcall(dofile, file) if not loaded then failed = failed + 1 print("not ok - load " .. file) print(" " .. tostring(lerr):gsub("\n", "\n ")) end for _, c in ipairs(cases) do local ok, err = true, nil for _, b in ipairs(c.befores) do local bok, berr = pcall(b) if not bok then ok, err = false, berr break end end if ok then ok, err = pcall(c.fn) end for _, a in ipairs(c.afters) do pcall(a) -- teardown always runs, even after a failure end if ok then passed = passed + 1 print("ok - " .. c.name) else failed = failed + 1 print("not ok - " .. c.name) print(" " .. tostring(err):gsub("\n", "\n ")) end end end print(string.format("\n%d passed, %d failed", passed, failed)) return failed end return M