From 6e2720090130f65e244a42a595a274dd5bad3d37 Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Sat, 2 Aug 2025 14:58:27 +0300 Subject: [PATCH] test(pack): add `vim.pack` tests --- runtime/ftplugin/nvim-pack.lua | 8 +- test/functional/plugin/pack_spec.lua | 1169 +++++++++++++++++++++++++- 2 files changed, 1137 insertions(+), 40 deletions(-) diff --git a/runtime/ftplugin/nvim-pack.lua b/runtime/ftplugin/nvim-pack.lua index 464fd88c0d..2929077255 100644 --- a/runtime/ftplugin/nvim-pack.lua +++ b/runtime/ftplugin/nvim-pack.lua @@ -5,6 +5,8 @@ local priority = 100 local hi_range = function(lnum, start_col, end_col, hl, pr) --- @type vim.api.keyset.set_extmark local opts = { end_row = lnum - 1, end_col = end_col, hl_group = hl, priority = pr or priority } + -- Set expanding gravity for easier testing. Should not make big difference. + opts.right_gravity, opts.end_right_gravity = false, true vim.api.nvim_buf_set_extmark(0, ns, lnum - 1, start_col, opts) end @@ -30,8 +32,10 @@ for i, l in ipairs(lines) do hi_range(i, cur_info:len(), end_col, 'DiagnosticInfo') -- Plugin state after update - local col = l:match('() %b()$') or l:len() - hi_range(i, col, l:len(), 'DiagnosticHint') + local col = l:match('() %b()$') + if col then + hi_range(i, col, l:len(), 'DiagnosticHint') + end elseif l:match('^> ') then -- Added change with possibly "breaking message" hi_range(i, 0, l:len(), 'Added') diff --git a/test/functional/plugin/pack_spec.lua b/test/functional/plugin/pack_spec.lua index 3442c0079a..c45693f34f 100644 --- a/test/functional/plugin/pack_spec.lua +++ b/test/functional/plugin/pack_spec.lua @@ -1,73 +1,1166 @@ +local t = require('test.testutil') +local n = require('test.functional.testnvim')() +local Screen = require('test.functional.ui.screen') +local skip_integ = os.getenv('NVIM_TEST_INTEG') ~= '1' + +local api = n.api +local fn = n.fn + +local eq = t.eq +local matches = t.matches +local pcall_err = t.pcall_err +local exec_lua = n.exec_lua + +-- Helpers ==================================================================== +-- Installed plugins ---------------------------------------------------------- + +local function pack_get_dir() + return vim.fs.joinpath(fn.stdpath('data'), 'site', 'pack', 'core', 'opt') +end + +local function pack_get_plug_path(plug_name) + return vim.fs.joinpath(pack_get_dir(), plug_name) +end + +local function pack_exists(plug_name) + local path = vim.fs.joinpath(pack_get_dir(), plug_name) + return vim.uv.fs_stat(path) ~= nil +end + +-- Test repos (to be installed) ----------------------------------------------- + +local repos_dir = vim.fs.abspath('test/functional/lua/pack-test-repos') + +--- Map from repo name to its proper `src` used in plugin spec +--- @type table +local repos_src = {} + +local function repo_get_path(repo_name) + vim.validate('repo_name', repo_name, 'string') + return vim.fs.joinpath(repos_dir, repo_name) +end + +local function repo_write_file(repo_name, rel_path, text, no_dedent, append) + local path = vim.fs.joinpath(repo_get_path(repo_name), rel_path) + fn.mkdir(vim.fs.dirname(path), 'p') + t.write_file(path, text, no_dedent, append) +end + +--- @return vim.SystemCompleted +local function system_sync(cmd, opts) + return exec_lua(function() + local obj = vim.system(cmd, opts) + + if opts and opts.timeout then + -- Minor delay before calling wait() so the timeout uv timer can have a headstart over the + -- internal call to vim.wait() in wait(). + vim.wait(10) + end + + local res = obj:wait() + + -- Check the process is no longer running + assert(not vim.api.nvim_get_proc(obj.pid), 'process still exists') + + return res + end) +end + +local function git_cmd(cmd, repo_name) + local git_cmd_prefix = { + 'git', + '-c', + 'gc.auto=0', + '-c', + 'user.name=Marvim', + '-c', + 'user.email=marvim@neovim.io', + '-c', + 'init.defaultBranch=main', + } + + cmd = vim.list_extend(git_cmd_prefix, cmd) + local cwd = repo_get_path(repo_name) + local sys_opts = { cwd = cwd, text = true, clear_env = true } + local out = system_sync(cmd, sys_opts) + if out.code ~= 0 then + error(out.stderr) + end + return (out.stdout:gsub('\n+$', '')) +end + +local function init_test_repo(repo_name) + local path = repo_get_path(repo_name) + fn.mkdir(path, 'p') + repos_src[repo_name] = 'file://' .. path + + git_cmd({ 'init' }, repo_name) +end + +local function git_add_commit(msg, repo_name) + git_cmd({ 'add', '*' }, repo_name) + git_cmd({ 'commit', '-m', msg }, repo_name) +end + +local function git_get_hash(rev, repo_name) + return git_cmd({ 'rev-list', '-1', '--abbrev-commit', rev }, repo_name) +end + +-- Common test repos ---------------------------------------------------------- +--- @type table +local repos_setup = {} + +function repos_setup.basic() + init_test_repo('basic') + + repo_write_file('basic', 'lua/basic.lua', 'return "basic init"') + git_add_commit('Initial commit for "basic"', 'basic') + repo_write_file('basic', 'lua/basic.lua', 'return "basic main"') + git_add_commit('Commit in `main` but not in `feat-branch`', 'basic') + + git_cmd({ 'checkout', 'main~' }, 'basic') + git_cmd({ 'checkout', '-b', 'feat-branch' }, 'basic') + + repo_write_file('basic', 'lua/basic.lua', 'return "basic some-tag"') + git_add_commit('Add commit for some tag', 'basic') + git_cmd({ 'tag', 'some-tag' }, 'basic') + + repo_write_file('basic', 'lua/basic.lua', 'return "basic feat-branch"') + git_add_commit('Add important feature', 'basic') + + -- Make sure that `main` is the default remote branch + git_cmd({ 'checkout', 'main' }, 'basic') +end + +function repos_setup.plugindirs() + init_test_repo('plugindirs') + + repo_write_file('plugindirs', 'lua/plugindirs.lua', 'return "plugindirs main"') + repo_write_file('plugindirs', 'plugin/dirs.lua', 'vim.g._plugin = true') + repo_write_file('plugindirs', 'plugin/dirs.vim', 'let g:_plugin_vim=v:true') + repo_write_file('plugindirs', 'plugin/sub/dirs.lua', 'vim.g._plugin_sub = true') + repo_write_file('plugindirs', 'plugin/bad % name.lua', 'vim.g._plugin_bad = true') + repo_write_file('plugindirs', 'after/plugin/dirs.lua', 'vim.g._after_plugin = true') + repo_write_file('plugindirs', 'after/plugin/dirs.vim', 'let g:_after_plugin_vim=v:true') + repo_write_file('plugindirs', 'after/plugin/sub/dirs.lua', 'vim.g._after_plugin_sub = true') + repo_write_file('plugindirs', 'after/plugin/bad % name.lua', 'vim.g._after_plugin_bad = true') + git_add_commit('Initial commit for "plugindirs"', 'plugindirs') +end + +function repos_setup.helptags() + init_test_repo('helptags') + repo_write_file('helptags', 'lua/helptags.lua', 'return "helptags main"') + repo_write_file('helptags', 'doc/my-test-help.txt', '*my-test-help*') + repo_write_file('helptags', 'doc/bad % name.txt', '*my-test-help-bad*') + repo_write_file('helptags', 'doc/bad % dir/file.txt', '*my-test-help-sub-bad*') + git_add_commit('Initial commit for "helptags"', 'helptags') +end + +function repos_setup.pluginerr() + init_test_repo('pluginerr') + + repo_write_file('pluginerr', 'lua/pluginerr.lua', 'return "pluginerr main"') + repo_write_file('pluginerr', 'plugin/err.lua', 'error("Wow, an error")') + git_add_commit('Initial commit for "pluginerr"', 'pluginerr') +end + +function repos_setup.defbranch() + init_test_repo('defbranch') + + repo_write_file('defbranch', 'lua/defbranch.lua', 'return "defbranch main"') + git_add_commit('Initial commit for "defbranch"', 'defbranch') + + -- Make `dev` the default remote branch + git_cmd({ 'checkout', '-b', 'dev' }, 'defbranch') + + repo_write_file('defbranch', 'lua/defbranch.lua', 'return "defbranch dev"') + git_add_commit('Add to new default branch', 'defbranch') +end + +function repos_setup.gitsuffix() + init_test_repo('gitsuffix.git') + + repo_write_file('gitsuffix.git', 'lua/gitsuffix.lua', 'return "gitsuffix main"') + git_add_commit('Initial commit for "gitsuffix"', 'gitsuffix.git') +end + +function repos_setup.semver() + init_test_repo('semver') + + local add_tag = function(name) + repo_write_file('semver', 'lua/semver.lua', 'return "semver ' .. name .. '"') + git_add_commit('Add version ' .. name, 'semver') + git_cmd({ 'tag', name }, 'semver') + end + + add_tag('v0.0.1') + add_tag('v0.0.2') + add_tag('v0.1.0') + add_tag('v0.1.1') + add_tag('v0.2.0-dev') + add_tag('v0.2.0') + add_tag('v0.3.0') + repo_write_file('semver', 'lua/semver.lua', 'return "semver middle-commit') + git_add_commit('Add middle commit', 'semver') + add_tag('0.3.1') + add_tag('v0.4') + add_tag('non-semver') + add_tag('v0.2.1') -- Intentionally add version not in order + add_tag('v1.0.0') +end + +-- Utility -------------------------------------------------------------------- + +local function watch_events(event) + exec_lua(function() + _G.event_log = _G.event_log or {} --- @type table[] + vim.api.nvim_create_autocmd(event, { + callback = function(ev) + table.insert(_G.event_log, { event = ev.event, match = ev.match, data = ev.data }) + end, + }) + end) +end + +--- @param log table[] +local function find_in_log(log, event, kind, repo_name, version) + local path = pack_get_plug_path(repo_name) + local spec = { name = repo_name, src = repos_src[repo_name], version = version } + local data = { kind = kind, path = path, spec = spec } + local entry = { event = event, match = vim.fs.abspath(path), data = data } + + local res = 0 + for i, tbl in ipairs(log) do + if vim.deep_equal(tbl, entry) then + res = i + break + end + end + eq(true, res > 0) + + return res +end + +local function validate_progress_report(title, step_names) + -- NOTE: Assumes that message history contains only progress report messages + local messages = vim.split(n.exec_capture('messages'), '\n') + local n_steps = #step_names + eq(n_steps + 2, #messages) + + local init_msg = ('vim.pack: 0%% %s (0/%d)'):format(title, n_steps) + eq(init_msg, messages[1]) + + local steps_seen = {} --- @type table + for i = 1, n_steps do + local percent = math.floor(100 * i / n_steps) + local msg = ('vim.pack: %3d%% %s (%d/%d)'):format(percent, title, i, n_steps) + -- NOTE: There is no guaranteed order (as it is async), so check that some + -- expected step name is used + local pattern = '^' .. vim.pesc(msg) .. ' %- (%S+)$' + local step = messages[i + 1]:match(pattern) + eq(true, vim.tbl_contains(step_names, step)) + steps_seen[step] = true + end + + -- Should report all steps + eq(n_steps, vim.tbl_count(steps_seen)) + + local final_msg = ('vim.pack: done %s (%d/%d)'):format(title, n_steps, n_steps) + eq(final_msg, messages[n_steps + 2]) +end + +local function is_jit() + return exec_lua('return package.loaded.jit ~= nil') +end + +-- Tests ====================================================================== + describe('vim.pack', function() + setup(function() + n.clear() + for _, r_setup in pairs(repos_setup) do + r_setup() + end + end) + + before_each(function() + n.clear() + end) + + after_each(function() + vim.fs.rm(pack_get_dir(), { force = true, recursive = true }) + end) + + teardown(function() + vim.fs.rm(repos_dir, { force = true, recursive = true }) + end) + describe('add()', function() - pending('works', function() - -- TODO + it('installs only once', function() + exec_lua(function() + vim.pack.add({ repos_src.basic }) + end) + n.clear() + + watch_events({ 'PackChanged' }) + exec_lua(function() + vim.pack.add({ repos_src.basic }) + end) + eq(exec_lua('return #_G.event_log'), 0) end) - pending('reports errors after loading', function() - -- TODO - -- Should handle (not let it terminate the function) and report errors from pack_add() + it('asks for installation confirmation', function() + exec_lua(function() + ---@diagnostic disable-next-line: duplicate-set-field + vim.fn.confirm = function(...) + _G.confirm_args = { ... } + -- Do not confirm installation to see what happens + return 0 + end + end) + + local err = pcall_err(exec_lua, function() + vim.pack.add({ repos_src.basic }) + end) + + matches('`basic`:\nInstallation was not confirmed', err) + eq(false, exec_lua('return pcall(require, "basic")')) + + local confirm_msg = 'These plugins will be installed:\n\n' .. repos_src.basic .. '\n' + eq({ confirm_msg, 'Proceed? &Yes\n&No', 1, 'Question' }, exec_lua('return _G.confirm_args')) end) - pending('respects after/', function() - -- TODO - -- Should source 'after/plugin/' directory (even nested files) after - -- all 'plugin/' files are sourced in all plugins from input. - -- - -- Should add 'after/' directory (if present) to 'runtimepath' + it('installs at proper version', function() + local out = exec_lua(function() + vim.pack.add({ + { src = repos_src.basic, version = 'feat-branch' }, + }) + -- Should have plugin available immediately after + return require('basic') + end) + + eq('basic feat-branch', out) + + local rtp = vim.tbl_map(t.fix_slashes, api.nvim_list_runtime_paths()) + local plug_path = pack_get_plug_path('basic') + local after_dir = vim.fs.joinpath(plug_path, 'after') + eq(true, vim.tbl_contains(rtp, plug_path)) + -- No 'after/' directory in runtimepath because it is not present in plugin + eq(false, vim.tbl_contains(rtp, after_dir)) end) - pending('normalizes each spec', function() - -- TODO - - -- TODO: Should properly infer `name` from `src` (as its basename - -- minus '.git' suffix) but allow '.git' suffix in explicit `name` + it('can install from the Internet', function() + t.skip(skip_integ, 'NVIM_TEST_INTEG not set: skipping network integration test') + exec_lua(function() + vim.pack.add({ 'https://github.com/neovim/nvim-lspconfig' }) + end) + eq(true, exec_lua('return pcall(require, "lspconfig")')) end) - pending('normalizes spec array', function() - -- TODO - -- Should silently ignore full duplicates (same `src`+`version`) - -- and error on conflicts. + it('shows progress report during installation', function() + exec_lua(function() + vim.pack.add({ repos_src.basic, repos_src.defbranch }) + end) + validate_progress_report('Installing plugins', { 'basic', 'defbranch' }) end) - pending('installs', function() - -- TODO + it('triggers relevant events', function() + watch_events({ 'PackChangedPre', 'PackChanged' }) - -- TODO: Should block code flow until all plugins are available on disk - -- and `:packadd` all of them (even just now installed) as a result. + exec_lua(function() + -- Should provide event-data respecting manual and inferred default `version` + vim.pack.add({ { src = repos_src.basic, version = 'feat-branch' }, repos_src.defbranch }) + end) + + local log = exec_lua('return _G.event_log') + local installpre_basic = find_in_log(log, 'PackChangedPre', 'install', 'basic', 'feat-branch') + local installpre_defbranch = find_in_log(log, 'PackChangedPre', 'install', 'defbranch', nil) + local updatepre_basic = find_in_log(log, 'PackChangedPre', 'update', 'basic', 'feat-branch') + local updatepre_defbranch = find_in_log(log, 'PackChangedPre', 'update', 'defbranch', 'dev') + local update_basic = find_in_log(log, 'PackChanged', 'update', 'basic', 'feat-branch') + local update_defbranch = find_in_log(log, 'PackChanged', 'update', 'defbranch', 'dev') + local install_basic = find_in_log(log, 'PackChanged', 'install', 'basic', 'feat-branch') + local install_defbranch = find_in_log(log, 'PackChanged', 'install', 'defbranch', 'dev') + eq(8, #log) + + -- NOTE: There is no guaranteed installation order among separate plugins (as it is async) + eq(true, installpre_basic < updatepre_basic) + eq(true, updatepre_basic < update_basic) + -- NOTE: "Install" is after "update" to indicate installation at correct version + eq(true, update_basic < install_basic) + + eq(true, installpre_defbranch < updatepre_defbranch) + eq(true, updatepre_defbranch < update_defbranch) + eq(true, update_defbranch < install_defbranch) + end) + + it('recognizes several `version` types', function() + local prev_commit = git_get_hash('HEAD~', 'defbranch') + exec_lua(function() + vim.pack.add({ + { src = repos_src.basic, version = 'some-tag' }, -- Tag + { src = repos_src.defbranch, version = prev_commit }, -- Commit hash + { src = repos_src.semver, version = vim.version.range('<1') }, -- Semver constraint + }) + end) + + eq('basic some-tag', exec_lua('return require("basic")')) + eq('defbranch main', exec_lua('return require("defbranch")')) + eq('semver v0.4', exec_lua('return require("semver")')) + end) + + it('respects plugin/ and after/plugin/ scripts', function() + local function validate(load, ref) + local opts = { load = load } + local out = exec_lua(function() + -- Should handle bad plugin directory name + vim.pack.add({ { src = repos_src.plugindirs, name = 'plugin % dirs' } }, opts) + return { + vim.g._plugin, + vim.g._plugin_vim, + vim.g._plugin_sub, + vim.g._plugin_bad, + vim.g._after_plugin, + vim.g._after_plugin_vim, + vim.g._after_plugin_sub, + vim.g._after_plugin_bad, + } + end) + + eq(ref, out) + + -- Should add necessary directories to runtimepath regardless of `opts.load` + local rtp = vim.tbl_map(t.fix_slashes, api.nvim_list_runtime_paths()) + local plug_path = pack_get_plug_path('plugin % dirs') + local after_dir = vim.fs.joinpath(plug_path, 'after') + eq(true, vim.tbl_contains(rtp, plug_path)) + eq(true, vim.tbl_contains(rtp, after_dir)) + end + + validate(nil, { true, true, true, true, true, true, true, true }) + + n.clear() + validate(false, {}) + end) + + it('generates help tags', function() + exec_lua(function() + vim.pack.add({ { src = repos_src.helptags, name = 'help tags' } }) + end) + local target_tags = fn.getcompletion('my-test', 'help') + table.sort(target_tags) + eq({ 'my-test-help', 'my-test-help-bad', 'my-test-help-sub-bad' }, target_tags) + end) + + it('reports install/load errors after loading all input', function() + t.skip(not is_jit(), "Non LuaJIT reports errors differently due to 'coxpcall'") + local validate = function(err_pat) + local err = pcall_err(exec_lua, function() + vim.pack.add({ + { src = repos_src.basic, version = 'wrong-version' }, -- Error during initial checkout + { src = repos_src.semver, version = vim.version.range('>=2.0.0') }, -- Missing version + { src = repos_src.plugindirs, version = 'main' }, + { src = repos_src.pluginerr, version = 'main' }, -- Error during 'plugin/' source + }) + end) + + matches(err_pat, err) + + -- Should have processed non-errored 'plugin/' and add to 'rtp' + eq('plugindirs main', exec_lua('return require("plugindirs")')) + eq(true, exec_lua('return vim.g._plugin')) + + -- Should add plugin to 'rtp' even if 'plugin/' has error + eq('pluginerr main', exec_lua('return require("pluginerr")')) + end + + -- During initial install + local err_pat_parts = { + 'vim%.pack', + '`basic`:\n', + -- Should report available branches and tags if revision is absent + '`wrong%-version`', + 'Available:\nTags: some%-tag\nBranches: feat%-branch, main', + -- Should report available branches and versions if no constraint match + '`semver`', + 'Available:\nVersions: v1%.0%.0, v0%.4, 0%.3%.1, v0%.3%.0.*\nBranches: main\n', + '`pluginerr`:\n', + 'Wow, an error', + } + validate(table.concat(err_pat_parts, '.*')) + + -- During loading already installed plugin. + n.clear() + -- NOTE: There is no error for wrong `version`, because there is no check + -- for already installed plugins. Might change in the future. + validate('vim%.pack.*`pluginerr`:\n.*Wow, an error') + end) + + it('normalizes each spec', function() + exec_lua(function() + vim.pack.add({ + repos_src.basic, -- String should be inferred as `{ src = ... }` + { src = repos_src.defbranch }, -- Default `version` is remote's default branch + { src = repos_src['gitsuffix.git'] }, -- Default `name` comes from `src` repo name + { src = repos_src.plugindirs, name = 'plugin/dirs' }, -- Ensure proper directory name + }) + end) + + eq('basic main', exec_lua('return require("basic")')) + eq('defbranch dev', exec_lua('return require("defbranch")')) + eq('gitsuffix main', exec_lua('return require("gitsuffix")')) + eq(true, exec_lua('return vim.g._plugin')) + + eq(true, pack_exists('gitsuffix')) + eq(true, pack_exists('dirs')) + end) + + it('handles problematic names', function() + exec_lua(function() + vim.pack.add({ { src = repos_src.basic, name = 'bad % name' } }) + end) + eq('basic main', exec_lua('return require("basic")')) + end) + + it('validates input', function() + local validate = function(err_pat, input) + local add_input = function() + vim.pack.add(input) + end + matches(err_pat, pcall_err(exec_lua, add_input)) + end + + -- Separate spec entries + validate('list', repos_src.basic) + validate('spec:.*table', { 1 }) + validate('spec%.src:.*string', { { src = 1 } }) + validate('spec%.src:.*non%-empty string', { { src = '' } }) + validate('spec%.name:.*string', { { src = repos_src.basic, name = 1 } }) + validate('spec%.name:.*non%-empty string', { { src = repos_src.basic, name = '' } }) + validate( + 'spec%.version:.*string or vim%.VersionRange', + { { src = repos_src.basic, version = 1 } } + ) + + -- Conflicts in input array + local version_conflict = { + { src = repos_src.basic, version = 'feat-branch' }, + { src = repos_src.basic, version = 'main' }, + } + validate('Conflicting `version` for `basic`.*feat%-branch.*main', version_conflict) + + local src_conflict = { + { src = repos_src.basic, name = 'my-plugin' }, + { src = repos_src.semver, name = 'my-plugin' }, + } + validate('Conflicting `src` for `my%-plugin`.*basic.*semver', src_conflict) end) end) describe('update()', function() - pending('works', function() - -- TODO + -- Lua source code for the tested plugin named "fetch" + local fetch_lua_file = vim.fs.joinpath(pack_get_plug_path('fetch'), 'lua', 'fetch.lua') + -- Table with hashes used to test confirmation buffer and log content + local hashes --- @type table - -- TODO: Should work with both added and not added plugins + before_each(function() + -- Create a dedicated clean repo for which "push changes" will be mocked + init_test_repo('fetch') + + repo_write_file('fetch', 'lua/fetch.lua', 'return "fetch init"') + git_add_commit('Initial commit for "fetch"', 'fetch') + + repo_write_file('fetch', 'lua/fetch.lua', 'return "fetch main"') + git_add_commit('Commit from `main` to be removed', 'fetch') + + hashes = { fetch_head = git_get_hash('HEAD', 'fetch') } + + -- Install initial versions of tested plugins + exec_lua(function() + vim.pack.add({ + repos_src.fetch, + { src = repos_src.semver, version = 'v0.3.0' }, + repos_src.defbranch, + }) + end) + n.clear() + + -- Mock remote repo update + -- - Force push + repo_write_file('fetch', 'lua/fetch.lua', 'return "fetch new"') + git_cmd({ 'add', '*' }, 'fetch') + git_cmd({ 'commit', '--amend', '-m', 'Commit to be added 1' }, 'fetch') + + -- - Presence of a tag (should be shown in changelog) + git_cmd({ 'tag', 'dev-tag' }, 'fetch') + + repo_write_file('fetch', 'lua/fetch.lua', 'return "fetch new 2"') + git_add_commit('Commit to be added 2', 'fetch') end) - pending('suggests newer tags if there are no updates', function() - -- TODO + after_each(function() + pcall(vim.fs.rm, repo_get_path('fetch'), { force = true, recursive = true }) + local log_path = vim.fs.joinpath(fn.stdpath('log'), 'nvim-pack.log') + pcall(vim.fs.rm, log_path, { force = true }) + end) - -- TODO: Should not suggest tags that point to the current state. - -- Even if there is one/several and located at start/middle/end. + describe('confirmation buffer', function() + it('works', function() + exec_lua(function() + vim.pack.add({ + repos_src.fetch, + { src = repos_src.semver, version = 'v0.3.0' }, + { src = repos_src.defbranch, version = 'does-not-exist' }, + }) + end) + eq({ 'return "fetch main"' }, fn.readfile(fetch_lua_file)) + + exec_lua(function() + -- Enable highlighting of special filetype + vim.cmd('filetype plugin on') + vim.pack.update() + end) + + -- Buffer should be special and shown in a separate tabpage + eq(2, #api.nvim_list_tabpages()) + eq(2, fn.tabpagenr()) + eq(api.nvim_get_option_value('filetype', {}), 'nvim-pack') + eq(api.nvim_get_option_value('modifiable', {}), false) + eq(api.nvim_get_option_value('buftype', {}), 'acwrite') + local confirm_bufnr = api.nvim_get_current_buf() + local confirm_winnr = api.nvim_get_current_win() + local confirm_tabpage = api.nvim_get_current_tabpage() + eq(api.nvim_buf_get_name(0), 'nvim-pack://' .. confirm_bufnr .. '/confirm-update') + + -- Adjust lines for a more robust screenshot testing + local fetch_src = repos_src.fetch + local fetch_path = pack_get_plug_path('fetch') + local semver_src = repos_src.semver + local semver_path = pack_get_plug_path('semver') + + exec_lua(function() + -- Replace matches in line to preserve extmark highlighting + local function replace_in_line(i, pattern, repl) + local line = vim.api.nvim_buf_get_lines(0, i - 1, i, false)[1] + local from, to = line:find(pattern) + while from and to do + vim.api.nvim_buf_set_text(0, i - 1, from - 1, i - 1, to, { repl }) + line = vim.api.nvim_buf_get_lines(0, i - 1, i, false)[1] + from, to = line:find(pattern) + end + end + + vim.bo.modifiable = true + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + local pack_runtime = vim.fs.joinpath(vim.env.VIMRUNTIME, 'lua', 'vim', 'pack.lua') + -- NOTE: replace path to `vim.pack` in error traceback accounting for + -- possibly different slashes on Windows + local pack_runtime_pattern = vim.pesc(pack_runtime):gsub('/', '[\\/]') .. ':%d+' + for i = 1, #lines do + replace_in_line(i, pack_runtime_pattern, 'VIM_PACK_RUNTIME') + replace_in_line(i, vim.pesc(fetch_path), 'FETCH_PATH') + replace_in_line(i, vim.pesc(fetch_src), 'FETCH_SRC') + replace_in_line(i, vim.pesc(semver_path), 'SEMVER_PATH') + replace_in_line(i, vim.pesc(semver_src), 'SEMVER_SRC') + end + vim.bo.modified = false + vim.bo.modifiable = false + end) + + -- Use screenshot to test highlighting, otherwise prefer text matching. + -- This requires computing target hashes on each test run because they + -- change due to source repos being cleanly created on each file test. + local screen + screen = Screen.new(85, 35) + + hashes.fetch_new = git_get_hash('HEAD', 'fetch') + hashes.fetch_new_prev = git_get_hash('HEAD~', 'fetch') + hashes.semver_head = git_get_hash('v0.3.0', 'semver') + + local tab_name = 'n' .. (t.is_os('win') and ':' or '') .. '//2/confirm-update' + + local screen_lines = { + ('{24: [No Name] }{5: %s }{2:%s }{24:X}|'):format( + tab_name, + t.is_os('win') and '' or ' ' + ), + '{19:^# Error ────────────────────────────────────────────────────────────────────────} |', + ' |', + '{19:## defbranch} |', + ' |', + ' VIM_PACK_RUNTIME: `does-not-exist` is not a branch/tag/commit. Available: |', + ' Tags: |', + ' Branches: dev, main |', + ' |', + '{101:# Update ───────────────────────────────────────────────────────────────────────} |', + ' |', + '{101:## fetch} |', + 'Path: {103:FETCH_PATH} |', + 'Source: {103:FETCH_SRC} |', + ('State before: {103:%s} |'):format( + hashes.fetch_head + ), + ('State after: {103:%s} {102:(main)} |'):format( + hashes.fetch_new + ), + ' |', + 'Pending updates: |', + ('{104:< %s │ Commit from `main` to be removed} |'):format( + hashes.fetch_head + ), + ('{105:> %s │ Commit to be added 2} |'):format( + hashes.fetch_new + ), + ('{105:> %s │ Commit to be added 1 (tag: dev-tag)} |'):format( + hashes.fetch_new_prev + ), + ' |', + '{102:# Same ─────────────────────────────────────────────────────────────────────────} |', + ' |', + '{102:## semver} |', + 'Path: {103:SEMVER_PATH} |', + 'Source: {103:SEMVER_SRC} |', + ('State: {103:%s} {102:(v0.3.0)} |'):format( + hashes.semver_head + ), + ' |', + 'Available newer versions: |', + '• {102:v1.0.0} |', + '• {102:v0.4} |', + '• {102:0.3.1} |', + '{1:~ }|', + ' |', + } + + screen:add_extra_attr_ids({ + [101] = { foreground = Screen.colors.Orange }, + [102] = { foreground = Screen.colors.LightGray }, + [103] = { foreground = Screen.colors.LightBlue }, + [104] = { foreground = Screen.colors.NvimDarkRed }, + [105] = { foreground = Screen.colors.NvimDarkGreen }, + }) + -- NOTE: Non LuaJIT reports errors differently due to 'coxpcall' + if is_jit() then + screen:expect(table.concat(screen_lines, '\n')) + end + + -- `:write` should confirm + n.exec('write') + + -- - Apply changes immediately + eq({ 'return "fetch new 2"' }, fn.readfile(fetch_lua_file)) + + -- - Clean up buffer+window+tabpage + eq(false, api.nvim_buf_is_valid(confirm_bufnr)) + eq(false, api.nvim_win_is_valid(confirm_winnr)) + eq(false, api.nvim_tabpage_is_valid(confirm_tabpage)) + + -- - Write to log file + local log_path = vim.fs.joinpath(fn.stdpath('log'), 'nvim-pack.log') + local log_lines = fn.readfile(log_path) + matches('========== Update %d%d%d%d%-%d%d%-%d%d %d%d:%d%d:%d%d ==========', log_lines[1]) + local ref_log_lines = { + '# Update ───────────────────────────────────────────────────────────────────────', + '', + '## fetch', + 'Path: ' .. fetch_path, + 'Source: ' .. fetch_src, + 'State before: ' .. hashes.fetch_head, + 'State after: ' .. hashes.fetch_new .. ' (main)', + '', + 'Pending updates:', + '< ' .. hashes.fetch_head .. ' │ Commit from `main` to be removed', + '> ' .. hashes.fetch_new .. ' │ Commit to be added 2', + '> ' .. hashes.fetch_new_prev .. ' │ Commit to be added 1 (tag: dev-tag)', + '', + } + eq(ref_log_lines, vim.list_slice(log_lines, 2)) + end) + + it('can be dismissed with `:quit`', function() + exec_lua(function() + vim.pack.add({ repos_src.fetch }) + vim.pack.update({ 'fetch' }) + end) + eq('nvim-pack', api.nvim_get_option_value('filetype', {})) + + -- Should not apply updates + n.exec('quit') + eq({ 'return "fetch main"' }, fn.readfile(fetch_lua_file)) + end) + + it('closes full tabpage', function() + exec_lua(function() + vim.pack.add({ repos_src.fetch }) + vim.pack.update() + end) + + -- Confirm with `:write` + local confirm_tabpage = api.nvim_get_current_tabpage() + n.exec('-tab split other-tab') + local other_tabpage = api.nvim_get_current_tabpage() + n.exec('tabnext') + n.exec('write') + eq(true, api.nvim_tabpage_is_valid(other_tabpage)) + eq(false, api.nvim_tabpage_is_valid(confirm_tabpage)) + + -- Not confirm with `:quit` + n.exec('tab split other-tab-2') + local other_tabpage_2 = api.nvim_get_current_tabpage() + exec_lua(function() + vim.pack.update() + end) + confirm_tabpage = api.nvim_get_current_tabpage() + + -- - Temporary split window in tabpage should not matter + n.exec('vsplit other-buf') + n.exec('wincmd w') + + n.exec('tabclose ' .. api.nvim_tabpage_get_number(other_tabpage_2)) + eq(confirm_tabpage, api.nvim_get_current_tabpage()) + n.exec('quit') + eq(false, api.nvim_tabpage_is_valid(confirm_tabpage)) + end) + + it('has in-process LSP features', function() + t.skip(not is_jit(), "Non LuaJIT reports errors differently due to 'coxpcall'") + exec_lua(function() + vim.pack.add({ + repos_src.fetch, + { src = repos_src.semver, version = 'v0.3.0' }, + { src = repos_src.defbranch, version = 'does-not-exist' }, + }) + vim.pack.update() + end) + + eq(1, exec_lua('return #vim.lsp.get_clients({ bufnr = 0 })')) + + -- textDocument/documentSymbol + exec_lua('vim.lsp.buf.document_symbol()') + local loclist = vim.tbl_map(function(x) --- @param x table + return { + lnum = x.lnum, --- @type integer + col = x.col, --- @type integer + end_lnum = x.end_lnum, --- @type integer + end_col = x.end_col, --- @type integer + text = x.text, --- @type string + } + end, fn.getloclist(0)) + local ref_loclist = { + { lnum = 1, col = 1, end_lnum = 9, end_col = 1, text = '[Namespace] Error' }, + { lnum = 3, col = 1, end_lnum = 9, end_col = 1, text = '[Module] defbranch' }, + { lnum = 9, col = 1, end_lnum = 22, end_col = 1, text = '[Namespace] Update' }, + { lnum = 11, col = 1, end_lnum = 22, end_col = 1, text = '[Module] fetch' }, + { lnum = 22, col = 1, end_lnum = 32, end_col = 1, text = '[Namespace] Same' }, + { lnum = 24, col = 1, end_lnum = 32, end_col = 1, text = '[Module] semver' }, + } + eq(ref_loclist, loclist) + + n.exec('lclose') + + -- textDocument/hover + local confirm_winnr = api.nvim_get_current_win() + local validate_hover = function(pos, commit_msg) + api.nvim_win_set_cursor(0, pos) + exec_lua(function() + vim.lsp.buf.hover() + -- Default hover is async shown in floating window + vim.wait(1000, function() + return #vim.api.nvim_tabpage_list_wins(0) > 1 + end) + end) + + local all_wins = api.nvim_tabpage_list_wins(0) + eq(2, #all_wins) + local float_winnr = all_wins[1] == confirm_winnr and all_wins[2] or all_wins[1] + eq(true, api.nvim_win_get_config(float_winnr).relative ~= '') + + local float_buf = api.nvim_win_get_buf(float_winnr) + local text = table.concat(api.nvim_buf_get_lines(float_buf, 0, -1, false), '\n') + + local ref_pattern = 'Marvim \nDate:.*' .. vim.pesc(commit_msg) + matches(ref_pattern, text) + end + + validate_hover({ 14, 0 }, 'Commit from `main` to be removed') + validate_hover({ 15, 0 }, 'Commit to be added 2') + validate_hover({ 18, 0 }, 'Commit from `main` to be removed') + validate_hover({ 19, 0 }, 'Commit to be added 2') + validate_hover({ 20, 0 }, 'Commit to be added 1') + validate_hover({ 27, 0 }, 'Add version v0.3.0') + validate_hover({ 30, 0 }, 'Add version v1.0.0') + validate_hover({ 31, 0 }, 'Add version v0.4') + validate_hover({ 32, 0 }, 'Add version 0.3.1') + end) + + it('suggests newer versions when on non-tagged commit', function() + local commit = git_get_hash('0.3.1~', 'semver') + exec_lua(function() + -- Make fresh install for cleaner test + vim.pack.del({ 'semver' }) + vim.pack.add({ { src = repos_src.semver, version = commit } }) + vim.pack.update({ 'semver' }) + end) + + -- Should correctly infer that 0.3.0 is the latest version and suggest + -- versions greater than that + local confirm_text = table.concat(api.nvim_buf_get_lines(0, 0, -1, false), '\n') + matches('Available newer versions:\n• v1%.0%.0\n• v0%.4\n• 0%.3%.1$', confirm_text) + end) + end) + + it('works with not active plugins', function() + exec_lua(function() + -- No plugins are added, but they are installed in `before_each()` + vim.pack.update({ 'fetch' }) + end) + eq({ 'return "fetch main"' }, fn.readfile(fetch_lua_file)) + n.exec('write') + eq({ 'return "fetch new 2"' }, fn.readfile(fetch_lua_file)) + end) + + it('can force update', function() + exec_lua(function() + vim.pack.add({ repos_src.fetch }) + vim.pack.update({ 'fetch' }, { force = true }) + end) + + -- Apply changes immediately + local fetch_src = repos_src.fetch + local fetch_path = pack_get_plug_path('fetch') + eq({ 'return "fetch new 2"' }, fn.readfile(fetch_lua_file)) + + -- No special buffer/window/tabpage + eq(1, #api.nvim_list_tabpages()) + eq(1, #api.nvim_list_wins()) + eq('', api.nvim_get_option_value('filetype', {})) + + -- Write to log file + hashes.fetch_new = git_get_hash('HEAD', 'fetch') + hashes.fetch_new_prev = git_get_hash('HEAD~', 'fetch') + + local log_path = vim.fs.joinpath(fn.stdpath('log'), 'nvim-pack.log') + local log_lines = fn.readfile(log_path) + matches('========== Update %d%d%d%d%-%d%d%-%d%d %d%d:%d%d:%d%d ==========', log_lines[1]) + local ref_log_lines = { + '# Update ───────────────────────────────────────────────────────────────────────', + '', + '## fetch', + 'Path: ' .. fetch_path, + 'Source: ' .. fetch_src, + 'State before: ' .. hashes.fetch_head, + 'State after: ' .. hashes.fetch_new .. ' (main)', + '', + 'Pending updates:', + '< ' .. hashes.fetch_head .. ' │ Commit from `main` to be removed', + '> ' .. hashes.fetch_new .. ' │ Commit to be added 2', + '> ' .. hashes.fetch_new_prev .. ' │ Commit to be added 1 (tag: dev-tag)', + '', + } + eq(ref_log_lines, vim.list_slice(log_lines, 2)) + end) + + it('shows progress report', function() + exec_lua(function() + vim.pack.add({ repos_src.fetch, repos_src.defbranch }) + vim.pack.update() + end) + + -- During initial download + validate_progress_report('Downloading updates', { 'fetch', 'defbranch' }) + n.exec('messages clear') + + -- During application (only for plugins that have updates) + n.exec('write') + validate_progress_report('Applying updates', { 'fetch' }) + + -- During force update + n.clear() + repo_write_file('fetch', 'lua/fetch.lua', 'return "fetch new 3"') + git_add_commit('Commit to be added 3', 'fetch') + + exec_lua(function() + vim.pack.add({ repos_src.fetch, repos_src.defbranch }) + vim.pack.update(nil, { force = true }) + end) + validate_progress_report('Updating', { 'fetch', 'defbranch' }) + end) + + it('triggers relevant events', function() + watch_events({ 'PackChangedPre', 'PackChanged' }) + exec_lua(function() + vim.pack.add({ repos_src.fetch, repos_src.defbranch }) + _G.event_log = {} + vim.pack.update() + end) + eq({}, exec_lua('return _G.event_log')) + + -- Should trigger relevant events only for actually updated plugins + n.exec('write') + local log = exec_lua('return _G.event_log') + eq(1, find_in_log(log, 'PackChangedPre', 'update', 'fetch', 'main')) + eq(2, find_in_log(log, 'PackChanged', 'update', 'fetch', 'main')) + eq(2, #log) + end) + + it('stashes before applying changes', function() + fn.writefile({ 'A text that will be stashed' }, fetch_lua_file) + exec_lua(function() + vim.pack.add({ repos_src.fetch }) + vim.pack.update() + vim.cmd('write') + end) + + local fetch_path = pack_get_plug_path('fetch') + local stash_list = system_sync({ 'git', 'stash', 'list' }, { cwd = fetch_path }).stdout or '' + matches('vim%.pack: %d%d%d%d%-%d%d%-%d%d %d%d:%d%d:%d%d Stash before checkout', stash_list) + + -- Update should still be applied + eq({ 'return "fetch new 2"' }, fn.readfile(fetch_lua_file)) + end) + + it('validates input', function() + local validate = function(err_pat, input) + local update_input = function() + vim.pack.update(input) + end + matches(err_pat, pcall_err(exec_lua, update_input)) + end + + validate('list', 1) + + -- Should first check if every plugin name represents installed plugin + -- If not - stop early before any update + exec_lua(function() + vim.pack.add({ repos_src.basic }) + end) + + validate('The following plugins are not installed: aaa, ccc', { 'aaa', 'basic', 'ccc' }) + + -- Empty list is allowed with warning + n.exec('messages clear') + exec_lua(function() + vim.pack.update({}) + end) + eq('vim.pack: Nothing to update', n.exec_capture('messages')) end) end) describe('get()', function() - pending('works', function() - -- TODO + local basic_spec = { name = 'basic', src = repos_src.basic, version = 'main' } + local basic_path = pack_get_plug_path('basic') + local defbranch_spec = { name = 'defbranch', src = repos_src.defbranch, version = 'dev' } + local defbranch_path = pack_get_plug_path('defbranch') + + it('returns list of available plugins', function() + -- Should work just after installation + exec_lua(function() + vim.pack.add({ repos_src.defbranch, repos_src.basic }) + end) + eq({ + -- Should preserve order in which plugins were `vim.pack.add()`ed + { active = true, path = defbranch_path, spec = defbranch_spec }, + { active = true, path = basic_path, spec = basic_spec }, + }, exec_lua('return vim.pack.get()')) + + -- Should also list non-active plugins + n.clear() + + exec_lua(function() + vim.pack.add({ repos_src.basic }) + end) + eq({ + -- Should first list active, then non-active + { active = true, path = basic_path, spec = basic_spec }, + { active = false, path = defbranch_path, spec = defbranch_spec }, + }, exec_lua('return vim.pack.get()')) end) - pending('works after `del()`', function() - -- TODO: Should not include removed plugins and still return list + it('works with `del()`', function() + exec_lua(function() + vim.pack.add({ repos_src.defbranch, repos_src.basic }) + end) - -- TODO: Should return corrent list inside `PackChanged` "delete" event + exec_lua(function() + _G.get_log = {} + vim.api.nvim_create_autocmd({ 'PackChangedPre', 'PackChanged' }, { + callback = function() + table.insert(_G.get_log, vim.pack.get()) + end, + }) + end) + + -- Should not include removed plugins immediately after they are removed, + -- while still returning list without holes + exec_lua('vim.pack.del({ "defbranch" })') + eq({ + { + { active = true, path = defbranch_path, spec = defbranch_spec }, + { active = true, path = basic_path, spec = basic_spec }, + }, + { + { active = true, path = basic_path, spec = basic_spec }, + }, + }, exec_lua('return _G.get_log')) end) end) describe('del()', function() - pending('works', function() - -- TODO + it('works', function() + exec_lua(function() + vim.pack.add({ repos_src.plugindirs, { src = repos_src.basic, version = 'feat-branch' } }) + end) + eq(true, pack_exists('basic')) + eq(true, pack_exists('plugindirs')) + + watch_events({ 'PackChangedPre', 'PackChanged' }) + + n.exec('messages clear') + exec_lua(function() + vim.pack.del({ 'basic', 'plugindirs' }) + end) + eq(false, pack_exists('basic')) + eq(false, pack_exists('plugindirs')) + + eq( + "vim.pack: Removed plugin 'plugindirs'\nvim.pack: Removed plugin 'basic'", + n.exec_capture('messages') + ) + + -- Should trigger relevant events in order as specified in `vim.pack.add()` + local log = exec_lua('return _G.event_log') + eq(1, find_in_log(log, 'PackChangedPre', 'delete', 'plugindirs', 'main')) + eq(2, find_in_log(log, 'PackChanged', 'delete', 'plugindirs', 'main')) + eq(3, find_in_log(log, 'PackChangedPre', 'delete', 'basic', 'feat-branch')) + eq(4, find_in_log(log, 'PackChanged', 'delete', 'basic', 'feat-branch')) + eq(4, #log) + end) + + it('validates input', function() + local validate = function(err_pat, input) + local del_input = function() + vim.pack.del(input) + end + matches(err_pat, pcall_err(exec_lua, del_input)) + end + + validate('list', nil) + + -- Should first check if every plugin name represents installed plugin + -- If not - stop early before any delete + exec_lua(function() + vim.pack.add({ repos_src.basic }) + end) + + validate('The following plugins are not installed: aaa, ccc', { 'aaa', 'basic', 'ccc' }) + eq(true, pack_exists('basic')) + + -- Empty list is allowed with warning + n.exec('messages clear') + exec_lua(function() + vim.pack.del({}) + end) + eq('vim.pack: Nothing to remove', n.exec_capture('messages')) end) end) end)