Files
arti-pack/tests/installer_spec.lua
T
unkin-agent e5a260ad25
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
Fetch the ref and url the spec actually names
A ref goes into the archive URL verbatim and an explicit url goes to curl
as a positional, so both could name one thing and fetch another.

- reject "#", "?", "%", "[", "]", "{" and "}" in a tag or branch, which
  curl or the server would turn into a different path
- pass "--" before the url so curl cannot read it as an option
- document the ref rules in README and the help file
2026-09-12 01:04:53 +10:00

306 lines
12 KiB
Lua

local t = require('tests.harness')
local fs = require('arti-pack.fs')
local installer = require('arti-pack.installer')
local Lock = require('arti-pack.lock')
local spec_mod = require('arti-pack.spec')
local zipfile = require('tests.zipfile')
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
-- Build a tag archive that looks like the real thing: everything wrapped in a
-- single "<repo>-<version>" directory.
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')
vim.fn.mkdir(work .. '/' .. root .. '/doc', 'p')
write(work .. '/' .. root .. '/lua/marker.lua', 'return "' .. version .. '"')
write(work .. '/' .. root .. '/doc/thing.txt', '*thing.txt*\tA help tag\n')
local archive = string.format('%s/%s.%s', tmpdir(), root, ext)
local cmd
if ext == 'zip' then
cmd = { 'zip', '-q', '-r', archive, root }
else
cmd = { 'tar', '-czf', archive, root }
end
local out = vim.system(cmd, { cwd = work, text = true }):wait()
assert(out.code == 0, 'failed to build fixture archive: ' .. tostring(out.stderr))
return archive
end
local function new_ctx()
local base = tmpdir()
return {
registry = 'file://unused',
pack_dir = fs.mkdirp(base .. '/pack/arti-pack'),
cache_dir = fs.mkdirp(base .. '/cache'),
concurrency = 2,
timeout = 30,
lock = Lock.new(base .. '/lock.json'),
}
end
local function run(ctx, specs)
local results
installer.run(ctx, specs, function(r)
results = r
end)
assert(
vim.wait(30000, function()
return results ~= nil
end, 20),
'installer.run never called back'
)
return results
end
local function spec_for(source, tag, archive, extra)
local entry = vim.tbl_extend('force', { source, tag = tag, url = 'file://' .. archive }, extra or {})
return assert(spec_mod.parse(entry))
end
-- Branch fixtures are built in Lua, so these run with no zip or tar around.
t.describe('installer.run with a branch spec', function()
local function branch_spec(source, branch, archive)
return assert(spec_mod.parse({ source, branch = branch, url = 'file://' .. archive }))
end
t.it('installs a branch archive and records the branch, not a tag', function()
local ctx = new_ctx()
local archive = zipfile.write(tmpdir() .. '/cmp-path-main.zip', 'cmp-path-main', 'first')
local results = run(ctx, { branch_spec('hrsh7th/cmp-path', 'main', archive) })
t.eq('installed', results[1].status, results[1].err)
t.truthy(fs.exists(ctx.pack_dir .. '/start/cmp-path/lua/marker.lua'), 'wrapper directory is stripped')
local entry = ctx.lock:get('cmp-path')
t.eq('main', entry.branch)
t.eq(nil, entry.tag, 'a branch is not recorded as a version')
t.eq(64, #entry.digest, 'the fetched archive is identified by digest')
t.truthy(type(entry.installed_at) == 'number', 'the fetch time is recorded')
end)
t.it('reports a refetch that brought back the same bytes as unchanged', function()
local ctx = new_ctx()
local archive = zipfile.write(tmpdir() .. '/cmp-path-main.zip', 'cmp-path-main', 'first')
run(ctx, { branch_spec('hrsh7th/cmp-path', 'main', archive) })
local before = ctx.lock:get('cmp-path').digest
local results = run(ctx, { branch_spec('hrsh7th/cmp-path', 'main', archive) })
t.eq('unchanged', results[1].status, results[1].err)
t.eq(before, ctx.lock:get('cmp-path').digest)
end)
t.it('reports a moved branch as an update and replaces the tree', function()
local ctx = new_ctx()
local path = tmpdir() .. '/cmp-path-main.zip'
run(ctx, { branch_spec('hrsh7th/cmp-path', 'main', zipfile.write(path, 'cmp-path-main', 'first')) })
local before = ctx.lock:get('cmp-path').digest
write(ctx.pack_dir .. '/start/cmp-path/leftover.txt', 'stale')
zipfile.write(path, 'cmp-path-main', 'second')
local results = run(ctx, { branch_spec('hrsh7th/cmp-path', 'main', path) })
t.eq('updated', results[1].status, results[1].err)
t.falsy(fs.exists(ctx.pack_dir .. '/start/cmp-path/leftover.txt'), 'stale files are cleared')
t.truthy(before ~= ctx.lock:get('cmp-path').digest, 'the recorded digest follows the branch')
end)
t.it('never serves a branch from the archive cache', function()
local ctx = new_ctx()
local archive = zipfile.write(tmpdir() .. '/cmp-path-main.zip', 'cmp-path-main', 'first')
run(ctx, { branch_spec('hrsh7th/cmp-path', 'main', archive) })
-- With the source gone a cached archive would silently succeed, which is
-- exactly what would make an update a no-op.
vim.fn.delete(archive)
local results = run(ctx, { branch_spec('hrsh7th/cmp-path', 'main', archive) })
t.eq('failed', results[1].status)
t.matches('download failed', results[1].err)
end)
end)
-- curl reads a leading-dash positional as an option, so a url that looks like a
-- flag must reach it as a url or not at all. These fixtures are built in Lua,
-- so they run with no zip or tar around.
t.describe('installer.run with a url that looks like a curl option', function()
t.it('does not let a url write a file of its choosing', function()
local ctx = new_ctx()
local victim = tmpdir() .. '/written-by-curl'
local s = assert(spec_mod.parse({ 'owner/sneaky', tag = 'v1', url = '--stderr=' .. victim }))
local results = run(ctx, { s })
t.eq('failed', results[1].status)
t.falsy(fs.exists(victim), 'the url ran as a curl option and created a file')
end)
t.it('does not let a url pull curl options in from a config file', function()
local ctx = new_ctx()
local archive = zipfile.write(tmpdir() .. '/sneaky-v1.zip', 'sneaky-v1', 'first')
local conf = write(tmpdir() .. '/curl.conf', string.format('url = "file://%s"\n', archive))
local s = assert(spec_mod.parse({ 'owner/sneaky', tag = 'v1', url = '--config=' .. conf }))
local results = run(ctx, { s })
t.eq('failed', results[1].status, 'curl fetched a url the spec never named')
t.falsy(fs.exists(ctx.pack_dir .. '/start/sneaky'), 'nothing was installed')
t.falsy(ctx.lock:get('sneaky'), 'nothing was recorded')
end)
t.it('still installs from a plain file:// url', function()
local ctx = new_ctx()
local archive = zipfile.write(tmpdir() .. '/honest-v1.zip', 'honest-v1', 'first')
local s = assert(spec_mod.parse({ 'owner/honest', tag = 'v1', url = 'file://' .. archive }))
local results = run(ctx, { s })
t.eq('installed', results[1].status, results[1].err)
t.truthy(fs.exists(ctx.pack_dir .. '/start/honest/lua/marker.lua'))
end)
end)
t.describe('installer.run', function()
if not have('zip') or not have('tar') then
t.it('SKIPPED: zip/tar unavailable to build fixtures', function() end)
return
end
t.it('installs a zip into pack/start and strips the wrapper directory', function()
local archive = build_archive('zip', 'plenary.nvim', '0.1.4')
local ctx = new_ctx()
local results = run(ctx, { spec_for('nvim-lua/plenary.nvim', 'v0.1.4', archive) })
t.eq(1, #results)
t.eq('installed', results[1].status, results[1].err)
local dir = ctx.pack_dir .. '/start/plenary.nvim'
t.truthy(fs.exists(dir .. '/lua/marker.lua'), 'plugin files land at the top level')
t.falsy(fs.exists(dir .. '/plenary.nvim-0.1.4'), 'wrapper directory is stripped')
t.eq('v0.1.4', ctx.lock:get('plenary.nvim').tag)
end)
t.it('installs into pack/opt when opt is set', function()
local archive = build_archive('zip', 'lazy.nvim', '1.0.0')
local ctx = new_ctx()
local results = run(ctx, { spec_for('folke/lazy.nvim', 'v1.0.0', archive, { opt = true }) })
t.eq('installed', results[1].status, results[1].err)
t.truthy(fs.exists(ctx.pack_dir .. '/opt/lazy.nvim/lua/marker.lua'))
t.falsy(fs.exists(ctx.pack_dir .. '/start/lazy.nvim'))
t.eq(true, ctx.lock:get('lazy.nvim').opt)
end)
t.it('installs a tar.gz the same way', function()
local archive = build_archive('tar.gz', 'thing.nvim', '2.0.0')
local ctx = new_ctx()
local results = run(ctx, { spec_for('owner/thing.nvim', 'v2.0.0', archive, { ext = 'tar.gz' }) })
t.eq('installed', results[1].status, results[1].err)
t.truthy(fs.exists(ctx.pack_dir .. '/start/thing.nvim/lua/marker.lua'))
end)
t.it('replaces the old tree on a tag change and reports an update', function()
local ctx = new_ctx()
run(ctx, { spec_for('owner/thing.nvim', 'v1.0.0', build_archive('zip', 'thing.nvim', '1.0.0')) })
write(ctx.pack_dir .. '/start/thing.nvim/leftover.txt', 'stale')
local results = run(ctx, { spec_for('owner/thing.nvim', 'v2.0.0', build_archive('zip', 'thing.nvim', '2.0.0')) })
t.eq('updated', results[1].status, results[1].err)
t.falsy(fs.exists(ctx.pack_dir .. '/start/thing.nvim/leftover.txt'), 'stale files are cleared')
t.eq('v2.0.0', ctx.lock:get('thing.nvim').tag)
end)
t.it('moves a plugin between start and opt without leaving a copy behind', function()
local archive = build_archive('zip', 'thing.nvim', '1.0.0')
local ctx = new_ctx()
run(ctx, { spec_for('owner/thing.nvim', 'v1.0.0', archive) })
run(ctx, { spec_for('owner/thing.nvim', 'v1.0.0', archive, { opt = true }) })
t.truthy(fs.exists(ctx.pack_dir .. '/opt/thing.nvim'))
t.falsy(fs.exists(ctx.pack_dir .. '/start/thing.nvim'))
end)
t.it('fails one plugin without abandoning the rest of the run', function()
local ctx = new_ctx()
local results = run(ctx, {
spec_for('owner/good', 'v1.0.0', build_archive('zip', 'good', '1.0.0')),
spec_for('owner/missing', 'v1.0.0', tmpdir() .. '/does-not-exist.zip'),
spec_for('owner/alsogood', 'v1.0.0', build_archive('zip', 'alsogood', '1.0.0')),
})
local byname = {}
for _, r in ipairs(results) do
byname[r.name] = r
end
t.eq(3, #results)
t.eq('installed', byname.good.status)
t.eq('installed', byname.alsogood.status)
t.eq('failed', byname.missing.status)
t.matches('download failed', byname.missing.err)
t.falsy(ctx.lock:get('missing'), 'a failed plugin is not recorded')
end)
t.it('rejects a download that is not an archive and leaves nothing behind', function()
local ctx = new_ctx()
local bogus = write(tmpdir() .. '/bogus.zip', '<html>404 not found</html>')
local results = run(ctx, { spec_for('owner/bogus', 'v1.0.0', bogus) })
t.eq('failed', results[1].status)
t.matches('not a zip', results[1].err)
t.falsy(fs.exists(ctx.pack_dir .. '/start/bogus'))
end)
t.it('reuses a cached archive instead of downloading again', function()
local ctx = new_ctx()
local archive = build_archive('zip', 'cached.nvim', '1.0.0')
run(ctx, { spec_for('owner/cached.nvim', 'v1.0.0', archive) })
t.truthy(fs.exists(ctx.cache_dir .. '/cached.nvim-v1.0.0.zip'), 'archive is cached by name and tag')
-- Delete the source the url points at: a second run can only succeed from cache.
vim.fn.delete(archive)
fs.rm_rf(ctx.pack_dir .. '/start/cached.nvim')
local results = run(ctx, { spec_for('owner/cached.nvim', 'v1.0.0', archive) })
t.eq('updated', results[1].status, results[1].err)
t.truthy(fs.exists(ctx.pack_dir .. '/start/cached.nvim/lua/marker.lua'))
end)
t.it('installs several plugins concurrently', function()
local ctx = new_ctx()
local specs = {}
for i = 1, 5 do
local name = 'plug' .. i
table.insert(specs, spec_for('owner/' .. name, 'v1.0.0', build_archive('zip', name, '1.0.0')))
end
local results = run(ctx, specs)
t.eq(5, #results)
for _, r in ipairs(results) do
t.eq('installed', r.status, r.err)
t.truthy(fs.exists(ctx.pack_dir .. '/start/' .. r.name .. '/lua/marker.lua'))
end
end)
t.it('returns immediately for an empty spec list', function()
t.eq({}, run(new_ctx(), {}))
end)
t.it('leaves no staging directory behind', function()
local ctx = new_ctx()
run(ctx, { spec_for('owner/thing.nvim', 'v1.0.0', build_archive('zip', 'thing.nvim', '1.0.0')) })
t.eq({}, fs.entries(ctx.pack_dir .. '/.stage'))
end)
end)