local t = require('tests.harness') local fs = require('arti-pack.fs') local Lock = require('arti-pack.lock') local arti = require('arti-pack') local function tmpdir() local dir = vim.fn.tempname() vim.fn.mkdir(dir, 'p') return dir end local function write(path, contents) local fd = assert(io.open(path, 'wb')) fd:write(contents) fd:close() return path end local function have(cmd) return vim.fn.executable(cmd) == 1 end local env -- Point stdpath at a throwaway tree so each test gets its own pack dir, cache -- and lockfile, and collect notifications instead of printing them. local function use_env() local base = tmpdir() env = { data = fs.mkdirp(base .. '/data'), cache = fs.mkdirp(base .. '/cache'), notices = {}, } env.pack_dir = env.data .. '/site/pack/arti-pack' env.lock_path = env.data .. '/arti-pack/lock.json' return env end use_env() local real_stdpath = vim.fn.stdpath local real_notify = vim.notify vim.fn.stdpath = function(what) if what == 'data' then return env.data elseif what == 'cache' then return env.cache end return real_stdpath(what) end vim.notify = function(msg, level) table.insert(env.notices, { msg = msg, level = level }) end local function notices() local out = {} for _, n in ipairs(env.notices) do table.insert(out, n.msg) end return table.concat(out, '\n') end local function last_notice() return env.notices[#env.notices] and env.notices[#env.notices].msg or '' end local function lock() return Lock.new(env.lock_path):load() end local function record(name, tag, opt) local l = lock() l:set(name, { tag = tag or 'v1', opt = opt == true }) assert(l:save()) end local function place_dir(name, sub) local dir = fs.mkdirp(env.pack_dir .. '/' .. sub .. '/' .. name) write(dir .. '/marker.lua', 'return true') return dir end local function install_fake(name, sub, tag) place_dir(name, sub) record(name, tag, sub == 'opt') end -- sync finishes asynchronously; its closing notification is the only signal. local function is_terminal(msg) return msg:find('everything up to date', 1, true) ~= nil or msg:find('%d+ installed') ~= nil or msg:find('%d+ updated') ~= nil or msg:find('%d+ failed') ~= nil end local function drain(from) assert( vim.wait(30000, function() for i = from + 1, #env.notices do if is_terminal(env.notices[i].msg) then return true end end return false end, 20), 'sync never finished: ' .. notices() ) end local function sync(opts) local before = #env.notices arti.sync(opts) drain(before) end local function build_archive(ext, repo, version) local work = tmpdir() local root = string.format('%s-%s', repo, version) vim.fn.mkdir(work .. '/' .. root .. '/lua', 'p') write(work .. '/' .. root .. '/lua/marker.lua', 'return "' .. version .. '"') local archive = string.format('%s/%s.%s', tmpdir(), root, ext) local out = vim.system({ 'zip', '-q', '-r', archive, root }, { cwd = work, text = true }):wait() assert(out.code == 0, 'failed to build fixture archive: ' .. tostring(out.stderr)) return archive end local function plugin_entry(source, tag, archive, extra) return vim.tbl_extend('force', { source, tag = tag, url = 'file://' .. archive }, extra or {}) end t.describe('arti-pack.setup', function() t.it('creates the start and opt pack directories', function() use_env() arti.setup({ auto_sync = false }) t.truthy(fs.exists(env.pack_dir .. '/start'), 'start directory is created') t.truthy(fs.exists(env.pack_dir .. '/opt'), 'opt directory is created') end) t.it('keeps the defaults for options the caller omits', function() use_env() arti.setup({ auto_sync = false }) t.eq(4, arti.config.concurrency) t.eq(120, arti.config.timeout) end) t.it('ships no registry default', function() use_env() arti.setup({ auto_sync = false }) t.eq(nil, arti.config.registry, 'registry is required, so nothing is assumed') end) t.it('rejects a registry that is not an http(s) url', function() for _, bad in ipairs({ '', 'artifacts.example.com/gh', 'ftp://artifacts.example.com', 42, {} }) do use_env() arti.setup({ auto_sync = false, registry = bad }) t.matches('is not an http%(s%) URL', last_notice(), 'rejects ' .. vim.inspect(bad)) t.matches("setup{ registry = 'https://", last_notice(), 'says how to set it') end end) t.it('applies the options the caller does set', function() use_env() arti.setup({ auto_sync = false, registry = 'https://example.test/gh', concurrency = 1 }) t.eq('https://example.test/gh', arti.config.registry) t.eq(1, arti.config.concurrency) t.eq(120, arti.config.timeout, 'untouched options keep their default') end) t.it('does not splice defaults into the caller plugin list', function() use_env() arti.setup({ auto_sync = false, plugins = { { 'owner/one', tag = 'v1' }, 'owner/two' } }) t.eq(2, #arti.config.plugins) t.eq('owner/one', arti.config.plugins[1][1]) t.eq('owner/two', arti.config.plugins[2]) end) t.it('forgets the plugin list of an earlier setup call', function() use_env() arti.setup({ auto_sync = false, plugins = { { 'owner/one', tag = 'v1' } } }) arti.setup({ auto_sync = false }) t.eq({}, arti.config.plugins) end) t.it('syncs on its own when auto_sync is left on', function() use_env() local before = #env.notices arti.setup({ plugins = {} }) drain(before) t.matches('everything up to date', notices()) end) end) t.describe('arti-pack without a registry', function() t.it('refuses to sync a plugin it would have to resolve against the registry', function() use_env() arti.setup({ auto_sync = false, plugins = { { 'owner/thing.nvim', tag = 'v1' } } }) arti.sync() t.matches('registry is not configured', last_notice()) t.matches("setup{ registry = 'https://", last_notice(), 'says how to set it') t.falsy(fs.exists(env.pack_dir .. '/start/thing.nvim'), 'nothing is downloaded') end) t.it('names the registry option when a command runs before setup', function() use_env() arti.config.registry = nil arti.config.plugins = { { 'owner/thing.nvim', tag = 'v1' } } arti.sync() t.matches('registry is not configured', last_notice()) end) t.it('still syncs plugins that carry their own url', function() if not have('curl') then return end use_env() local before = #env.notices arti.setup({ auto_sync = false, plugins = { plugin_entry('owner/thing.nvim', 'v1.0.0', tmpdir() .. '/absent.zip') }, }) arti.sync() drain(before) t.falsy(notices():find('registry', 1, true), 'a url-only run never asks for a registry') end) t.it('cleans without one', function() use_env() arti.setup({ auto_sync = false, plugins = {} }) install_fake('drop.nvim', 'start') arti.clean() t.matches('removed drop.nvim', last_notice()) end) t.it('lists without one', function() use_env() arti.setup({ auto_sync = false, plugins = { { 'owner/here.nvim', tag = 'v1' } } }) install_fake('here.nvim', 'start', 'v1') t.matches('installed$', arti.list()[1]) end) end) t.describe('arti-pack.sync', function() if not have('zip') or not have('unzip') then t.it('SKIPPED: zip/unzip unavailable', function() end) return end t.it('installs a configured plugin and records it in the lockfile', function() use_env() local archive = build_archive('zip', 'thing.nvim', '1.0.0') arti.setup({ auto_sync = false, plugins = { plugin_entry('owner/thing.nvim', 'v1.0.0', archive) } }) sync() t.truthy(fs.exists(env.pack_dir .. '/start/thing.nvim/lua/marker.lua'), 'plugin is unpacked into pack/start') t.eq('v1.0.0', lock():get('thing.nvim').tag) end) t.it('installs an opt plugin under pack/opt', function() use_env() local archive = build_archive('zip', 'lazy.nvim', '1.0.0') arti.setup({ auto_sync = false, plugins = { plugin_entry('folke/lazy.nvim', 'v1.0.0', archive, { opt = true }) }, }) sync() t.truthy(fs.exists(env.pack_dir .. '/opt/lazy.nvim/lua/marker.lua')) t.falsy(fs.exists(env.pack_dir .. '/start/lazy.nvim')) end) t.it('does no work on a second run', function() use_env() local archive = build_archive('zip', 'thing.nvim', '1.0.0') arti.setup({ auto_sync = false, plugins = { plugin_entry('owner/thing.nvim', 'v1.0.0', archive) } }) sync() env.notices = {} sync() t.matches('everything up to date', last_notice()) end) t.it('reinstalls a current plugin when forced', function() use_env() local archive = build_archive('zip', 'thing.nvim', '1.0.0') arti.setup({ auto_sync = false, plugins = { plugin_entry('owner/thing.nvim', 'v1.0.0', archive) } }) sync() fs.rm_rf(env.pack_dir .. '/start/thing.nvim') env.notices = {} sync({ force = true }) t.matches('1 updated', last_notice()) t.truthy(fs.exists(env.pack_dir .. '/start/thing.nvim/lua/marker.lua')) end) t.it('reinstalls a plugin whose directory disappeared', function() use_env() local archive = build_archive('zip', 'thing.nvim', '1.0.0') arti.setup({ auto_sync = false, plugins = { plugin_entry('owner/thing.nvim', 'v1.0.0', archive) } }) sync() fs.rm_rf(env.pack_dir .. '/start/thing.nvim') sync() t.truthy(fs.exists(env.pack_dir .. '/start/thing.nvim/lua/marker.lua')) end) t.it('reports a bad spec and still installs the good ones', function() use_env() local archive = build_archive('zip', 'thing.nvim', '1.0.0') arti.setup({ auto_sync = false, plugins = { plugin_entry('owner/thing.nvim', 'v1.0.0', archive), { 'not a source' } }, }) sync() t.matches('plugins%[2%]', notices()) t.matches('invalid source', notices()) t.truthy(fs.exists(env.pack_dir .. '/start/thing.nvim/lua/marker.lua')) end) t.it('surfaces an install failure instead of recording the plugin', function() use_env() arti.setup({ auto_sync = false, plugins = { plugin_entry('owner/thing.nvim', 'v1.0.0', tmpdir() .. '/absent.zip') }, }) sync() t.matches('1 failed', last_notice()) t.falsy(lock():get('thing.nvim'), 'a failed install is not locked') end) t.it('refuses to start a second sync while one is running', function() use_env() local archive = build_archive('zip', 'thing.nvim', '1.0.0') arti.setup({ auto_sync = false, plugins = { plugin_entry('owner/thing.nvim', 'v1.0.0', archive) } }) local before = #env.notices arti.sync() arti.sync() t.matches('a sync is already running', last_notice()) drain(before) end) end) t.describe('arti-pack.clean', function() t.it('removes the directories and lock entries that are no longer configured', function() use_env() arti.setup({ auto_sync = false, plugins = { { 'owner/keep.nvim', tag = 'v1' } } }) install_fake('keep.nvim', 'start') install_fake('drop.nvim', 'start') install_fake('dropopt.nvim', 'opt') arti.clean() t.truthy(fs.exists(env.pack_dir .. '/start/keep.nvim'), 'a configured plugin is kept') t.falsy(fs.exists(env.pack_dir .. '/start/drop.nvim')) t.falsy(fs.exists(env.pack_dir .. '/opt/dropopt.nvim')) t.eq({ 'keep.nvim' }, lock():names()) t.matches('removed drop.nvim, dropopt.nvim', last_notice()) end) t.it('does nothing when every install is still configured', function() use_env() arti.setup({ auto_sync = false, plugins = { { 'owner/keep.nvim', tag = 'v1' } } }) install_fake('keep.nvim', 'start') arti.clean() t.matches('nothing to clean', last_notice()) t.truthy(fs.exists(env.pack_dir .. '/start/keep.nvim')) t.eq({ 'keep.nvim' }, lock():names()) end) t.it('drops a lock entry whose directory is already gone', function() use_env() arti.setup({ auto_sync = false, plugins = {} }) record('ghost.nvim', 'v1') arti.clean() t.eq({}, lock():names()) end) -- Regression: an unparseable entry used to vanish from the wanted set, so -- clean deleted a plugin the user still had configured. t.it('deletes nothing while any plugin spec is in error', function() use_env() arti.setup({ auto_sync = false, plugins = { { 'owner/keep.nvim', tag = 'v1' }, { 'owner/broken.nvim' }, }, }) install_fake('keep.nvim', 'start') install_fake('broken.nvim', 'start') install_fake('orphan.nvim', 'start') arti.clean() t.truthy( fs.exists(env.pack_dir .. '/start/broken.nvim/marker.lua'), 'a still-configured plugin survives its own spec error' ) t.truthy(fs.exists(env.pack_dir .. '/start/keep.nvim')) t.truthy(fs.exists(env.pack_dir .. '/start/orphan.nvim'), 'nothing is deleted from a partial spec set') t.eq({ 'broken.nvim', 'keep.nvim', 'orphan.nvim' }, lock():names(), 'lock entries are left alone') t.matches('missing a tag', notices(), 'the parse error is reported') t.matches('clean aborted', last_notice()) end) end) t.describe('arti-pack.list', function() local function line_for(lines, needle) for _, line in ipairs(lines) do if line:find(needle, 1, true) then return line end end return nil end t.it('says so when nothing is configured', function() use_env() arti.setup({ auto_sync = false }) t.eq({ 'no plugins configured' }, arti.list()) end) t.it('reports the state of every configured and locked plugin', function() use_env() arti.setup({ auto_sync = false, plugins = { { 'owner/here.nvim', tag = 'v1' }, { 'owner/old.nvim', tag = 'v2' }, { 'owner/gone.nvim', tag = 'v1' }, { 'owner/new.nvim', tag = 'v1' }, }, }) install_fake('here.nvim', 'start', 'v1') install_fake('old.nvim', 'start', 'v1') record('gone.nvim', 'v1') record('orphan.nvim', 'v1') local lines = arti.list() t.matches('installed$', line_for(lines, 'owner/here.nvim')) t.matches('outdated %(v1%)$', line_for(lines, 'owner/old.nvim')) t.matches('gone from disk$', line_for(lines, 'owner/gone.nvim')) t.matches('missing$', line_for(lines, 'owner/new.nvim')) t.matches('orphaned$', line_for(lines, 'orphan.nvim')) t.eq(5, #lines) end) t.it('lists spec errors alongside the plugins', function() use_env() arti.setup({ auto_sync = false, plugins = { { 'owner/ok.nvim', tag = 'v1' }, 'bogus' } }) local lines = arti.list() t.eq(2, #lines) t.matches('^error: plugins%[2%]', line_for(lines, 'error:')) end) end) vim.fn.stdpath = real_stdpath vim.notify = real_notify