diff --git a/.github/scripts/verify-nvim.lua b/.github/scripts/verify-nvim.lua deleted file mode 100644 index d556379..0000000 --- a/.github/scripts/verify-nvim.lua +++ /dev/null @@ -1,52 +0,0 @@ -local plugins = { - "fzf-lua", - "oil", - "harpoon", - "cmp", - "conform", - "flash", - "gitsigns", - "todo-comments", - "render-markdown", -} - -for _, mod in ipairs(plugins) do - local ok, err = pcall(require, mod) - if not ok then - io.stderr:write("FAIL plugin " .. mod .. ": " .. tostring(err) .. "\n") - vim.cmd("cquit 1") - end -end - -local tools = { - "stylua", - "lua-language-server", - "basedpyright", - "bash-language-server", - "clangd", - "rg", - "fd", - "fzf", -} - -for _, t in ipairs(tools) do - if vim.fn.executable(t) == 0 then - io.stderr:write("FAIL tool not on PATH: " .. t .. "\n") - vim.cmd("cquit 1") - end -end - -local config_checks = { - { "mapleader", function() return vim.g.mapleader == "," end }, - { ":Oil command", function() return vim.fn.exists(":Oil") == 2 end }, - { "kanagawa colorscheme", function() return vim.g.colors_name == "kanagawa" end }, -} - -for _, check in ipairs(config_checks) do - if not check[2]() then - io.stderr:write("FAIL config check: " .. check[1] .. "\n") - vim.cmd("cquit 1") - end -end - -print("verify-nvim: ok") diff --git a/.github/workflows/test-install-scripts.yml b/.github/workflows/test-install-scripts.yml index 87f81de..bef93e3 100644 --- a/.github/workflows/test-install-scripts.yml +++ b/.github/workflows/test-install-scripts.yml @@ -34,7 +34,6 @@ jobs: filters: | nvim: - 'install/nvim/**' - - '.github/scripts/verify-nvim.lua' - '.github/workflows/test-install-scripts.yml' kitty: - 'install/kitty.sh' @@ -82,17 +81,9 @@ jobs: which nvim nvim --version | head -5 - - name: Test nvim startup + - name: Run nvim test suite shell: bash - run: | - export PATH="$HOME/.nix-profile/bin:$PATH" - nvim --headless "+qa" - - - name: Verify plugins and bundled tools load - shell: bash - run: | - export PATH="$HOME/.nix-profile/bin:$PATH" - nvim --headless -c "luafile .github/scripts/verify-nvim.lua" +qa + run: nix run ./install/nvim#test test-kitty-install: needs: changes diff --git a/install/nvim/config/lua/plugins/lsp.lua b/install/nvim/config/lua/plugins/lsp.lua index 4d6df23..02151e4 100644 --- a/install/nvim/config/lua/plugins/lsp.lua +++ b/install/nvim/config/lua/plugins/lsp.lua @@ -101,6 +101,16 @@ local servers = { telemetry = { enable = false, }, + runtime = { + version = "LuaJIT", + }, + workspace = { + checkThirdParty = false, + library = { + vim.env.VIMRUNTIME, + vim.api.nvim_get_runtime_file("lua/lspconfig", false)[1], + }, + }, }, }, }, diff --git a/install/nvim/flake.lock b/install/nvim/flake.lock index affeabc..a57f1a1 100644 --- a/install/nvim/flake.lock +++ b/install/nvim/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1746904237, - "narHash": "sha256-3e+AVBczosP5dCLQmMoMEogM57gmZ2qrVSrmq9aResQ=", + "lastModified": 1785454630, + "narHash": "sha256-LQy14TZp77TwbQf40gg1V3jo8FwJG0jGDkAH+zRHqg8=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "d89fc19e405cb2d55ce7cc114356846a0ee5e956", + "rev": "1559d3daa3ecc813a650b79375ea61b6741b8746", "type": "github" }, "original": { diff --git a/install/nvim/flake.nix b/install/nvim/flake.nix index 3c4eaf9..b7873c7 100644 --- a/install/nvim/flake.nix +++ b/install/nvim/flake.nix @@ -51,7 +51,7 @@ lua-language-server basedpyright clang-tools - nodePackages.bash-language-server + bash-language-server stylua python3Packages.yapf fzf @@ -82,9 +82,43 @@ wrapperArgs = neovimConfig.wrapperArgs ++ [ "--prefix" "PATH" ":" (pkgs.lib.makeBinPath extraBinaries) ]; }); + + nvimTest = pkgs.writeShellApplication { + name = "nvim-test"; + runtimeInputs = [ nvim pkgs.coreutils ]; + text = '' + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + export XDG_DATA_HOME="$tmp/data" + export XDG_STATE_HOME="$tmp/state" + export XDG_CACHE_HOME="$tmp/cache" + mkdir -p "$XDG_DATA_HOME" "$XDG_STATE_HOME" "$XDG_CACHE_HOME" + + # Mirror the live-or-baked pattern the config itself uses, so local + # edits are testable without committing them for nix to see. + tests="$HOME/dotfiles/install/nvim/tests" + if [ ! -d "$tests" ]; then + tests="${./tests}" + fi + + status=0 + nvim --headless \ + --cmd "lua vim.g.nvim_tests_dir = '$tests'" \ + -c "luafile $tests/run.lua" || status=$? + + rm -rf "$tmp" + trap - EXIT + exit "$status" + ''; + }; in { packages.nvim = nvim; + packages.test = nvimTest; packages.default = nvim; + apps.test = { + type = "app"; + program = "${nvimTest}/bin/nvim-test"; + }; } ); } diff --git a/install/nvim/tests/helpers.lua b/install/nvim/tests/helpers.lua new file mode 100644 index 0000000..3272d4f --- /dev/null +++ b/install/nvim/tests/helpers.lua @@ -0,0 +1,61 @@ +-- Shared bootstrap for the nvim config test suite. +-- +-- The nix wrapper starts Neovim with the config split across two mechanisms: +-- VIMINIT="lua dofile('/nix/store/-init.lua')" (environment) +-- --cmd "set packpath^=/nix/store/-vim-pack-dir" (argv) +-- --cmd "set rtp^=" (argv) +-- +-- mini.test spawns child processes with a hardcoded `--clean`, which discards +-- both. So we recover them from the running parent and pass them to the child +-- explicitly. Without this a child has no mapleader and no plugins. + +local M = {} + +--- Reconstruct the argv a child needs in order to load the real config. +---@return string[] +function M.config_args() + local viminit = vim.env.VIMINIT + assert(viminit, "VIMINIT unset - tests must run under the nix-wrapped nvim") + + local init = viminit:match("dofile%(.([^'\"]+).%)") + assert(init, "could not extract init path from VIMINIT: " .. viminit) + + local pack + for _, p in ipairs(vim.opt.packpath:get()) do + if p:match("vim%-pack%-dir") then + pack = p + break + end + end + assert(pack, "no vim-pack-dir on packpath") + + return { + "--cmd", + "set packpath^=" .. pack, + "--cmd", + "set rtp^=" .. pack, + "-u", + init, + } +end + +--- A child Neovim preloaded with the args needed to reach the real config. +function M.new_child() + local child = MiniTest.new_child_neovim() + child.args = M.config_args() + return child +end + +--- A test set that gives every case a freshly restarted child. +function M.new_set(child) + return MiniTest.new_set { + hooks = { + pre_case = function() + child.restart(child.args) + end, + post_once = child.stop, + }, + } +end + +return M diff --git a/install/nvim/tests/run.lua b/install/nvim/tests/run.lua new file mode 100644 index 0000000..271fa7d --- /dev/null +++ b/install/nvim/tests/run.lua @@ -0,0 +1,39 @@ +-- Entry point. `vim.g.nvim_tests_dir` is set by the flake app. +local dir = vim.g.nvim_tests_dir +assert(dir, "vim.g.nvim_tests_dir not set - run via `nix run ./install/nvim#test`") + +package.path = dir .. "/?.lua;" .. package.path + +require("mini.test").setup { + collect = { + find_files = function() + return vim.fn.globpath(dir, "test_*.lua", true, true) + end, + }, +} + +-- MiniTest.collect() re-raises (via `error`) any failure while sourcing a +-- test file (e.g. a broken top-level `assert` in a shared helper), and +-- MiniTest.run() does not guard that call. Left unguarded, nvim prints the +-- error (E5113) and then just sits there in headless mode instead of exiting +-- nonzero, turning a bootstrap bug into a CI hang. Guard it ourselves. +local ok, err = pcall(MiniTest.run) + +if not ok then + vim.api.nvim_err_writeln("nvim-test: bootstrap error: " .. tostring(err)) + vim.cmd "cquit 1" +end + +-- MiniTest.run() calls `cquit 0`/`cquit 1` itself (via the stdout reporter's +-- `finish` hook) as soon as at least one case was collected, for both the +-- passing and failing case, and that never returns control here. So reaching +-- this point means zero cases were collected - e.g. the glob or tests dir +-- resolved wrong - which mini.test would otherwise treat as a vacuous pass. +-- Refuse that instead of exiting 0 having tested nothing. +local all_cases = MiniTest.current.all_cases +if all_cases == nil or #all_cases == 0 then + vim.api.nvim_err_writeln "nvim-test: collected 0 test cases - refusing to report success" + vim.cmd "cquit 1" +end + +vim.cmd "qall!" diff --git a/install/nvim/tests/test_behavior.lua b/install/nvim/tests/test_behavior.lua new file mode 100644 index 0000000..42a37d3 --- /dev/null +++ b/install/nvim/tests/test_behavior.lua @@ -0,0 +1,169 @@ +local helpers = require "helpers" + +local child = helpers.new_child() +local T = helpers.new_set(child) +local eq = MiniTest.expect.equality + +T["conform formats lua with stylua"] = function() + child.lua [[ + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "local x = 1" }) + vim.bo.filetype = "lua" + require("conform").format { bufnr = 0, async = false, lsp_format = "never" } + ]] + eq(child.lua_get "vim.api.nvim_buf_get_lines(0, 0, 1, false)[1]", "local x = 1") +end + +T["conform formats python with yapf"] = function() + child.lua [[ + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "x = 1" }) + vim.bo.filetype = "python" + -- conform's default timeout_ms is 1000. That's tight for the *first* + -- ever process spawn out of a freshly restarted child: process creation + -- plus python/yapf interpreter startup can eat most or all of a second + -- before yapf has produced a single byte, with zero relation to whether + -- yapf actually works. That's not a rare edge case here - pre_case + -- restarts the child before every test (see helpers.lua), so this is a + -- cold first spawn on every single run, local or CI, and CI runners in + -- particular have no warm OS/page cache to shorten it further. Give the + -- call a generous, bounded budget to absorb that one-time startup cost. + -- This does not paper over a real formatter regression: if yapf is + -- actually broken (bad output, crash, or a genuine hang) the buffer + -- still won't equal the expected result below, and the timeout still + -- bounds how long a truly hung process can block the suite. + require("conform").format { bufnr = 0, async = false, lsp_format = "never", timeout_ms = 15000 } + ]] + eq(child.lua_get "vim.api.nvim_buf_get_lines(0, 0, 1, false)[1]", "x = 1") +end + +T["harpoon list round-trips"] = function() + child.lua [[ + _G.h = require("harpoon") + vim.cmd.edit("install/nvim/flake.nix") + _G.h:list():add() + ]] + eq(child.lua_get "_G.h:list():length()", 1) + eq(child.lua_get '_G.h:list():get(1).value ~= nil', true) +end + +T["treesitter highlights a cpp buffer"] = function() + child.lua [[ + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "int main() { return 0; }" }) + vim.bo.filetype = "cpp" + local parser = vim.treesitter.get_parser(0, "cpp") + local tree = parser:parse()[1] + local query = vim.treesitter.query.get("cpp", "highlights") + _G.captures = 0 + for _ in query:iter_captures(tree:root(), 0, 0, -1) do + _G.captures = _G.captures + 1 + end + ]] + MiniTest.expect.no_equality(child.lua_get "_G.captures", 0) +end + +T["oil lists a directory"] = function() + child.lua [[ vim.cmd.edit("oil://" .. vim.fn.getcwd() .. "/install/nvim") ]] + child.lua [[ vim.wait(3000, function() return vim.api.nvim_buf_line_count(0) > 1 end) ]] + local lines = child.lua_get "table.concat(vim.api.nvim_buf_get_lines(0, 0, -1, false), '\\n')" + eq(lines:find "flake.nix" ~= nil, true) +end + +T["lua_ls attaches and completes"] = function() + child.lua [[ + vim.cmd.edit("install/nvim/config/lua/config/options.lua") + vim.bo.filetype = "lua" + ]] + local attached = child.lua_get [[ + vim.wait(30000, function() + return #vim.lsp.get_clients { bufnr = 0, name = "lua_ls" } > 0 + end, 200) + ]] + eq(attached, true) + + -- A single completion request fired right after attach reliably comes + -- back empty: lua_ls answers immediately from whatever state it has, and + -- learning that `have_nerd_font`/`clipboard` are members of vim.g takes a + -- workspace-wide scan of this repo's own lua files that (measured here) + -- finishes ~10s after attach, not before. So this polls the same request + -- instead of waiting on one in-flight callback - the assertion below is + -- unchanged, only how long we give the server to actually be ready is. + child.lua [[ + _G.items = {} + vim.wait(20000, function() + local items + vim.lsp.buf_request(0, "textDocument/completion", { + textDocument = vim.lsp.util.make_text_document_params(), + position = { line = 0, character = 6 }, + }, function(_, result) + items = result and (result.items or result) or {} + end) + vim.wait(1000, function() return items ~= nil end, 50) + _G.items = items or {} + return #_G.items > 0 + end, 300) + ]] + MiniTest.expect.no_equality(child.lua_get "#_G.items", 0) + + -- The check above only proves *some* completion came back, which is not + -- proof of anything -- see the comment on commit 145bf89 for how this bit + -- the suite before: without Lua.runtime/Lua.workspace.library, lua_ls has + -- no idea `vim` exists, and completion at this exact position (right after + -- "vim.g." in config/options.lua) fell back to scraping this repo's own + -- lua files for the name "g" (`clipboard`, `mapleader`, `maplocalleader`) + -- -- 3 non-empty items, zero `vim` knowledge, and the old count-only + -- assertion passed anyway. + -- + -- The obvious fix is "poll until `have_nerd_font` (a real vim.g member set + -- by config/options.lua) shows up in the completion labels instead of + -- until the list is merely non-empty" -- and that's what a first version + -- of this test did. It was reverted: probing it directly against this + -- flake's pinned lua-language-server (by editing this exact file/position + -- through the real wrapped nvim, both with and without 145bf89, waiting up + -- to 90s) showed completion at "vim.g." never offers `have_nerd_font` even + -- with the fix correctly applied -- lua_ls resolves `vim.g`'s type from + -- the runtime library as an index-signature-only table (any -> any, no + -- static field list), so once it has real type info it stops doing the + -- fallback name-scrape that produced `clipboard`/`mapleader` in the first + -- place. Asserting on a completion label here would fail red even when + -- everything is correct, which is exactly the false-negative Finding 2 was + -- raised to prevent -- so this does not do that. + -- + -- What IS a real, verified content signal: 145bf89's own commit message + -- names the directly observable symptom -- "Undefined global `vim`" on + -- every line that touches vim.*. Probing (same method as above, sampling + -- vim.diagnostic.get(0) once a second) showed this is fast and stable: 25 + -- such diagnostics on this exact buffer by 1s after attach without the + -- fix, 0 by 1s with it, unchanged for the next 20s in both cases -- unlike + -- completion, it does not depend on the multi-second workspace-wide scan. + -- + -- Two ways of turning that into "wait, then assert" were tried and + -- rejected because they never fire on a correctly-fixed config (i.e. they + -- ran out the full budget and reported false negatives): the + -- vim.diagnostic-level DiagnosticChanged autocmd, which in practice + -- doesn't fire here when there's nothing to report, and hooking the raw + -- textDocument/publishDiagnostics handler, which here fired for several + -- other files lua_ls happened to be background-diagnosing but never for + -- this one within the budget. So this polls vim.diagnostic.get(0) + -- directly instead, exiting the instant the bad diagnostic shows up + -- (fast, correct red); if it never shows up in 5s -- 5x the measured + -- worst case, and still well under this test's other budgets -- that's + -- treated as a real, settled "no `vim`-undefined diagnostic", not an + -- unsettled one. + local clean = child.lua_get [[ + (function() + local bad = false + vim.wait(5000, function() + for _, d in ipairs(vim.diagnostic.get(0)) do + if d.message:find "Undefined global" and d.message:find "vim" then + bad = true + return true + end + end + return false + end, 100) + return not bad + end)() + ]] + eq(clean, true) +end + +return T diff --git a/install/nvim/tests/test_contract.lua b/install/nvim/tests/test_contract.lua new file mode 100644 index 0000000..6c2e20c --- /dev/null +++ b/install/nvim/tests/test_contract.lua @@ -0,0 +1,192 @@ +local helpers = require "helpers" + +local child = helpers.new_child() +local T = helpers.new_set(child) +local eq = MiniTest.expect.equality + +--- Assert every listed expression is a callable, naming the path on failure. +local function expect_functions(paths) + for _, path in ipairs(paths) do + eq({ path, child.lua_get("type(" .. path .. ")") }, { path, "function" }) + end +end + +T["editing: plugin APIs"] = function() + expect_functions { + 'require("conform").setup', + 'require("conform").format', + 'require("mini.ai").setup', + 'require("mini.surround").setup', + 'require("mini.statusline").setup', + 'require("mini.statusline").section_location', + 'require("guess-indent").setup', + 'require("Comment").setup', + 'require("neoscroll").setup', + } +end + +T["navigation: fzf-lua APIs"] = function() + expect_functions { + 'require("fzf-lua").setup', + 'require("fzf-lua").register_ui_select', + 'require("fzf-lua").actions.file_edit_or_qf', + 'require("fzf-lua").help_tags', + 'require("fzf-lua").keymaps', + 'require("fzf-lua").files', + 'require("fzf-lua").grep_cword', + 'require("fzf-lua").live_grep', + 'require("fzf-lua").buffers', + 'require("fzf-lua").lines', + 'require("fzf-lua").treesitter', + 'require("fzf-lua").quickfix', + 'require("fzf-lua").git_status', + } +end + +T["navigation: lsp picker APIs"] = function() + expect_functions { + 'require("fzf-lua").lsp_code_actions', + 'require("fzf-lua").lsp_implementations', + 'require("fzf-lua").lsp_references', + 'require("fzf-lua").lsp_definitions', + 'require("fzf-lua").lsp_document_diagnostics', + 'require("fzf-lua").lsp_document_symbols', + 'require("fzf-lua").lsp_workspace_symbols', + } +end + +T["navigation: flash and oil APIs"] = function() + expect_functions { + 'require("flash").setup', + 'require("flash").jump', + 'require("flash").treesitter', + 'require("flash").remote', + 'require("flash").treesitter_search', + 'require("flash").toggle', + 'require("oil").setup', + 'require("oil").toggle_float', + } +end + +T["navigation: harpoon APIs"] = function() + expect_functions { + 'require("harpoon").setup', + 'require("harpoon").ui.toggle_quick_menu', + 'require("harpoon"):list().add', + 'require("harpoon"):list().next', + 'require("harpoon"):list().prev', + 'require("harpoon"):list().select', + } +end + +T["completion: cmp and luasnip APIs"] = function() + -- cmp.setup is `setmetatable({ global = ..., filetype = ..., ... }, { __call + -- = ... })` in nvim-cmp's own source (lua/cmp/init.lua) -- a callable table, + -- not a plain function. This is nvim-cmp's own long-standing multi-purpose + -- setup design, not something the 0.11.1 -> 0.12.4 bump changed, and the + -- config's own `cmp.setup { ... }` call in plugins/completion.lua works + -- (proven by "startup produces no errors" passing). type()=="function" + -- is the wrong test for callability here, so this one path is checked with + -- vim.is_callable instead of folding it into expect_functions. + eq(child.lua_get 'vim.is_callable(require("cmp").setup)', true) + expect_functions { + -- plugins/lsp.lua also pcalls this: `local ok, cmp_nvim_lsp = pcall(require, + -- "cmp_nvim_lsp")`. Unlike zk.lua, a failed require there doesn't stop + -- lsp.lua from running (no early return), so it can't be caught by a + -- keymap check -- it silently just never merges cmp's capabilities into + -- any LSP client, which is invisible to this test suite otherwise. This + -- is a pure-Lua module (part of the cmp plugin family, no external + -- binary), so asserting it loads is safe on a runner without the `zk` + -- binary or any language server installed. + 'require("cmp_nvim_lsp").default_capabilities', + 'require("cmp").mapping.preset.insert', + 'require("cmp").mapping.select_next_item', + 'require("cmp").mapping.select_prev_item', + 'require("cmp").mapping.scroll_docs', + 'require("cmp").mapping.confirm', + 'require("cmp").config.window.bordered', + 'require("luasnip").config.setup', + 'require("luasnip").lsp_expand', + 'require("luasnip").expand_or_locally_jumpable', + 'require("luasnip").locally_jumpable', + 'require("luasnip").expand_or_jump', + 'require("luasnip").jump', + 'require("nvim-autopairs").setup', + 'require("nvim-autopairs.completion.cmp").on_confirm_done', + } +end + +T["completion: cmp.event is subscribable"] = function() + eq(child.lua_get 'type(require("cmp").event.on)', "function") +end + +-- A `do ... end` block that dies partway registers no keymap, so one key per +-- block is a cheap proof that each ran to completion. +T["keymaps registered"] = function() + local keys = { + { "s", "n" }, -- flash + { "-", "n" }, -- oil + { "sf", "n" }, -- fzf-lua + { "ha", "n" }, -- harpoon + { "f", "n" }, -- conform + { "-", "n" }, -- oil float + -- plugins/zk.lua opens with `local ok, zk = pcall(require, "zk"); if not + -- ok then return end`. If the require ever breaks (e.g. zk-nvim renamed + -- or dropped by a nixpkgs bump), that pcall swallows the error and the + -- whole file returns early -- no keymaps, no error, nothing else in this + -- suite notices (see test_startup.lua's "every config module loaded" + -- comment). These map() calls are the last statements in the file, so + -- any one of them being registered proves the require succeeded and the + -- file ran to completion. This does not depend on the `zk` binary itself + -- being on PATH (deliberately not bundled in the flake, see README) -- + -- zk.setup() and vim.keymap.set() are pure Lua/zk-nvim calls that don't + -- shell out. + { "zn", "n" }, -- zk: new note + { "zo", "n" }, -- zk: open notes + { "zz", "n" }, -- zk: open last modified note + { "zm", "n" }, -- zk: new meeting note + { "zd", "n" }, -- zk: open/create daily note + { "zt", "n" }, -- zk: tags + } + for _, k in ipairs(keys) do + local got = child.lua_get("vim.fn.maparg(...) ~= ''", { k[1], k[2] }) + eq({ k[1], got }, { k[1], true }) + end +end + +T["bundled tools on PATH"] = function() + local tools = { + "stylua", + "lua-language-server", + "basedpyright", + "bash-language-server", + "clangd", + "rg", + "fd", + "fzf", + "yapf", + } + for _, t in ipairs(tools) do + eq({ t, child.lua_get("vim.fn.executable(...)", { t }) }, { t, 1 }) + end +end + +T["user commands defined"] = function() + local commands = { "Oil", "LspStart", "LspStop", "LspRestart", "LspLog", "LspInfo" } + for _, c in ipairs(commands) do + eq({ c, child.lua_get("vim.fn.exists(...)", { ":" .. c }) }, { c, 2 }) + end +end + +T["treesitter parsers available"] = function() + local langs = { + "bash", "cpp", "diff", "html", "lua", "luadoc", + "markdown", "markdown_inline", "python", "vim", "vimdoc", "yaml", + } + for _, l in ipairs(langs) do + local ok = child.lua_get("pcall(vim.treesitter.language.add, ...)", { l }) + eq({ l, ok }, { l, true }) + end +end + +return T diff --git a/install/nvim/tests/test_startup.lua b/install/nvim/tests/test_startup.lua new file mode 100644 index 0000000..cf10936 --- /dev/null +++ b/install/nvim/tests/test_startup.lua @@ -0,0 +1,57 @@ +local helpers = require "helpers" + +local child = helpers.new_child() +local T = helpers.new_set(child) +local eq = MiniTest.expect.equality + +-- init.lua uses bare `require` with no pcall. A plugin whose setup{} throws +-- prints an error and lets startup continue, so "nvim started" proves nothing. +-- These are the assertions that actually catch a half-applied config. +T["startup produces no errors"] = function() + eq(child.lua_get "vim.v.errmsg", "") + eq(child.cmd_capture "messages", "") +end + +-- LuaJIT leaves a non-nil sentinel in package.loaded while a module is loading, +-- even if the loader throws. This detects omitted `require` statements that silently +-- corrupt the config, but cannot pinpoint which module broke — it passes outright +-- if the terminal module (plugins.zk) throws. The "startup produces no errors" test +-- is the reliable guard against throwing modules. +T["every config module loaded"] = function() + local modules = { + "config.options", + "config.keymaps", + "config.autocmds", + "plugins", + "plugins.colorscheme", + "plugins.editing", + "plugins.navigation", + "plugins.git", + "plugins.syntax", + "plugins.markdown", + "plugins.completion", + "plugins.lsp", + "plugins.zk", + } + for _, mod in ipairs(modules) do + eq({ mod, child.lua_get("package.loaded[...] ~= nil", { mod }) }, { mod, true }) + end +end + +T["leader is set"] = function() + eq(child.lua_get "vim.g.mapleader", ",") +end + +T["colorscheme applied"] = function() + eq(child.lua_get "vim.g.colors_name", "kanagawa") +end + +T["options applied"] = function() + eq(child.lua_get "vim.o.winborder", "rounded") + eq(child.lua_get "vim.o.laststatus", 3) + eq(child.lua_get "vim.o.signcolumn", "yes") + eq(child.lua_get "vim.o.smartcase", true) + eq(child.lua_get "vim.o.showmode", false) +end + +return T