Files
arti-pack/tests/init_spec.lua
T
unkin-agent 5809a843c8
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Extract archives in pure Lua
Installing a plugin no longer needs unzip or tar on PATH, and entry paths
are vetted in Lua instead of trusting an external tool.

Add arti-pack.archive: a zip reader over zzlib's inflate and a tar reader
over its gunzip.
Reject absolute, parent-walking, backslashed and NUL-bearing entry names,
and any entry that is not a regular file or directory.
Verify every zip entry's CRC32 and size, and fail the install on mismatch.
Carry only the executable bit from archive modes.
Replace fs.extract_cmd with fs.extract and call it synchronously.
Drop unzip from the test pipeline and the stated requirements.
2026-09-10 22:48:25 +10:00

479 lines
14 KiB
Lua

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') then
t.it('SKIPPED: zip unavailable to build fixtures', 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