Files
vimpack/tests/plan_spec.lua
T
unkin-agent 35b9df9eba
ci/woodpecker/pr/pre-commit Pipeline failed
ci/woodpecker/pr/lint Pipeline failed
ci/woodpecker/pr/test Pipeline failed
Add vimpack: archive-based Neovim plugin manager
## Why
Cloning plugins from git ties the editor to GitHub uptime and moving branches; archives at a pinned version from our own registry are reproducible and reachable offline.

## How
- Add a registry resolver interface `(spec, registry) -> {url, headers, filename, sha256?}` with builtin `template` and `artifactapi` kinds, a per-plugin registry override and a `url` escape hatch.
- Take auth tokens only from an env var or a `token_cmd`; reject a literal token at setup.
- Download with curl, verify an optional sha256, extract to a staging dir and rename into `pack/vimpack/{start,opt}/<name>`, restoring the previous tree if the swap fails.
- Track installed name/version/sha256 in `stdpath('data')/vimpack/manifest.json` and skip plugins already at the wanted version.
- Add `:VimpackInstall`, `:VimpackUpdate`, `:VimpackClean` and `:VimpackStatus`, running downloads through `vim.system` under a concurrency cap.
- Hash with a pure-Lua SHA-256; `vim.fn.sha256()` rejects binary archives with E976.
- Add a dependency-free `tests/minimal.lua` harness and 57 specs covering resolution, auth, manifest, planning, checksum mismatch, orphan detection and end-to-end install.
- Add `.woodpecker/{test,lint,pre-commit}.yaml`, luacheck/stylua config and pre-commit hooks.
2026-09-05 13:28:53 +10:00

141 lines
4.8 KiB
Lua

local config = require('vimpack.config')
local installer = require('vimpack.installer')
local manifest = require('vimpack.manifest')
local plan = require('vimpack.plan')
local reg = { kind = 'template', url_template = 'https://h/${name}-${version}.tar.gz' }
local function cfg_with(plugins)
return assert(config.normalize({ registry = reg, plugins = plugins }))
end
describe('plan', function()
it('installs what is not on disk', function()
local built = plan.build(cfg_with({ { name = 'a', version = '1' } }), manifest.empty(), {})
assert_eq(1, #built.install)
assert_eq('not installed', built.install[1].reason)
end)
it('skips an install that already matches', function()
local data = manifest.empty()
manifest.set(data, 'a', { version = '1', sha256 = 'ff', opt = false })
local built = plan.build(cfg_with({ { name = 'a', version = '1' } }), data, { a = { opt = false, dir = '/x/a' } })
assert_eq(0, #built.install)
assert_eq(0, #built.update)
assert_eq(1, #built.skip)
end)
it('updates on a version bump', function()
local data = manifest.empty()
manifest.set(data, 'a', { version = '1', opt = false })
local built = plan.build(cfg_with({ { name = 'a', version = '2' } }), data, { a = { opt = false, dir = '/x/a' } })
assert_eq(1, #built.update)
assert_match('1 -> 2', built.update[1].reason)
end)
it('updates when the spec sha256 differs from the installed one', function()
local data = manifest.empty()
manifest.set(data, 'a', { version = '1', sha256 = 'aa', opt = false })
local built = plan.build(
cfg_with({ { name = 'a', version = '1', sha256 = 'bb' } }),
data,
{ a = { opt = false, dir = '/x/a' } }
)
assert_eq(1, #built.update)
assert_match('sha256', built.update[1].reason)
end)
it('updates when a plugin moves between start and opt', function()
local data = manifest.empty()
manifest.set(data, 'a', { version = '1', opt = false })
local built = plan.build(
cfg_with({ { name = 'a', version = '1', opt = true } }),
data,
{ a = { opt = false, dir = '/x/start/a' } }
)
assert_eq(1, #built.update)
assert_match('opt/', built.update[1].reason)
end)
it('updates a directory with no manifest entry', function()
local built =
plan.build(cfg_with({ { name = 'a', version = '1' } }), manifest.empty(), { a = { opt = false, dir = '/x/a' } })
assert_eq(1, #built.update)
assert_match('no manifest entry', built.update[1].reason)
end)
it('flags orphans on disk and in the manifest', function()
local data = manifest.empty()
manifest.set(data, 'ghost', { version = '9' })
local built = plan.build(
cfg_with({ { name = 'a', version = '1' } }),
data,
{ a = { opt = false, dir = '/x/a' }, old = { opt = true, dir = '/x/opt/old' } }
)
assert_eq(2, #built.orphans)
assert_eq('ghost', built.orphans[1].name)
assert_eq('old', built.orphans[2].name)
assert_eq('/x/opt/old', built.orphans[2].dir)
end)
end)
describe('status_rows', function()
it('reports one row per plugin plus orphans', function()
local data = manifest.empty()
manifest.set(data, 'a', { version = '1', opt = false })
local rows = plan.status_rows(
cfg_with({ { name = 'a', version = '2' }, { name = 'b', version = '1' } }),
data,
{ a = { opt = false, dir = '/x/a' }, gone = { opt = false, dir = '/x/gone' } }
)
assert_eq(3, #rows)
assert_eq({ 'a', 'b', 'gone' }, { rows[1].name, rows[2].name, rows[3].name })
assert_eq({ 'drift', 'missing', 'orphan' }, { rows[1].state, rows[2].state, rows[3].state })
assert_eq('1', rows[1].installed)
assert_eq('2', rows[1].wanted)
end)
end)
describe('run_tasks', function()
it('runs every item and respects the concurrency cap', function()
local items = { 1, 2, 3, 4, 5 }
local inflight, peak, seen = 0, 0, {}
local done_results
installer.run_tasks(items, 2, function(item, cb)
inflight = inflight + 1
peak = math.max(peak, inflight)
vim.schedule(function()
seen[#seen + 1] = item
inflight = inflight - 1
cb(true)
end)
end, function(results)
done_results = results
end)
vim.wait(1000, function()
return done_results ~= nil
end)
assert_eq(5, #done_results)
assert_eq(5, #seen)
assert_true(peak <= 2, 'peak concurrency was ' .. peak)
end)
it('completes immediately on an empty list', function()
local called = false
installer.run_tasks({}, 4, function() end, function()
called = true
end)
assert_true(called)
end)
it('carries failures through', function()
local results
installer.run_tasks({ 'a' }, 1, function(_, cb)
cb(false, 'boom')
end, function(r)
results = r
end)
assert_eq('boom', results[1].err)
end)
end)