Abort clean when a plugin spec fails to parse
An unparseable entry dropped out of the wanted set, so clean deleted the directory and lock entry of a plugin the user still has configured. Add tests/init_spec.lua covering setup, sync, clean and list.
This commit is contained in:
+10
-1
@@ -119,7 +119,16 @@ function M.sync(opts)
|
||||
end
|
||||
|
||||
function M.clean()
|
||||
local specs = spec_mod.parse_all(M.config.plugins)
|
||||
local specs, errors = spec_mod.parse_all(M.config.plugins)
|
||||
for _, err in ipairs(errors) do
|
||||
notify(err, vim.log.levels.ERROR)
|
||||
end
|
||||
-- An unparseable entry still names a plugin the user wants, so the set of
|
||||
-- wanted names is incomplete; deleting from it would remove a live install.
|
||||
if #errors > 0 then
|
||||
return notify('clean aborted, no plugin spec may be in error', vim.log.levels.WARN)
|
||||
end
|
||||
|
||||
local wanted = {}
|
||||
for _, spec in ipairs(specs) do
|
||||
wanted[spec.name] = true
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
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('https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github', arti.config.registry)
|
||||
t.eq(4, arti.config.concurrency)
|
||||
t.eq(120, arti.config.timeout)
|
||||
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.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
|
||||
+1
-1
@@ -8,7 +8,7 @@ package.path = table.concat({
|
||||
|
||||
local t = require('tests.harness')
|
||||
|
||||
for _, name in ipairs({ 'spec_spec', 'fs_spec', 'lock_spec', 'installer_spec' }) do
|
||||
for _, name in ipairs({ 'spec_spec', 'fs_spec', 'lock_spec', 'installer_spec', 'init_spec' }) do
|
||||
require('tests.' .. name)
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user