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 "-" 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', '404 not found') 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)