Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions nix/lib/sources.nix
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Reads lockDir/flake.lock as nothing more than a pin DB and returns a function
# from inputName to a store path via builtins.fetchTree.
# from inputName to a store path via builtins.fetchTree (or, for a relative `path:` node,
# builtins.path).
# It is never evaluated as a flake, so evaluation is fully pure (narHash is already fixed).
{ lockDir }:
let
Expand All @@ -11,11 +12,20 @@ let
nodeName = rootInputs.${inputName} or (throw "nvimx: input '${inputName}' not found in flake.lock");
locked = lock.nodes.${nodeName}.locked;
in
builtins.fetchTree {
inherit (locked) type narHash;
${if locked ? owner then "owner" else null} = locked.owner or null;
${if locked ? repo then "repo" else null} = locked.repo or null;
${if locked ? url then "url" else null} = locked.url or null;
${if locked ? rev then "rev" else null} = locked.rev or null;
${if locked ? ref then "ref" else null} = locked.ref or null;
}
if locked.type == "path" && !(locked ? narHash) && builtins.substring 0 1 locked.path != "/" then
# A relative path: input, which nix locks as `parent = []` with no narHash: resolve it the way
# nix does, against the lock flake's own directory. builtins.path copies just that directory,
# so the plugin's src does not change with every unrelated file in lockDir's source tree.
builtins.path {
path = lockDir + "/${locked.path}";
name = "source";
}
else
builtins.fetchTree {
inherit (locked) type narHash;
${if locked ? owner then "owner" else null} = locked.owner or null;
${if locked ? repo then "repo" else null} = locked.repo or null;
${if locked ? url then "url" else null} = locked.url or null;
${if locked ? rev then "rev" else null} = locked.rev or null;
${if locked ? ref then "ref" else null} = locked.ref or null;
}
2 changes: 2 additions & 0 deletions tests/checks/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,6 @@ pkgs.lib.genAttrs [
"dev-plugins"
"dev-patterns"
"extractor-dev-opts"
"e2e-offline"
"demo"
] (name: import (./. + "/${name}.nix") checkArgs)
4 changes: 4 additions & 0 deletions tests/checks/demo.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# `nix flake check` evaluates packages.* but never builds them; demo is the dogfooding artifact
# (nvimx's own env around tests/fixtures/basic-config), so build it here, where CI already runs.
{ pkgs, self, ... }:
self.packages.${pkgs.stdenv.hostPlatform.system}.demo
91 changes: 91 additions & 0 deletions tests/checks/e2e-offline.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# The whole loop a user runs, with no network (#30): config -> extract -> resolve -> genflake
# -> real `nix flake lock` -> makeEnv build -> the wrapped neovim launching. "Offline" means
# build-time: evaluating the flake still fetches its own pinned inputs, as every check does
# (lazy-nvim is one of them). No other check builds an env AND launches it with a user config,
# or runs a real nix: checks.lock-app stands a stub in for it.
#
# The env is built from tests/fixtures/e2e-offline/nvimx-lock, whose flake.lock holds relative
# `path:` nodes (nix/lib/sources.nix reads them) instead of github ones, so the three vendored
# plugins need no fetch. The pipeline never writes path sources, so plugins.json / flake.nix are
# real pipeline output (step 1 re-derives them) and only flake.lock is written by hand:
# F=tests/fixtures/e2e-offline
# jq --slurpfile p $F/nvimx-lock/plugins.json '
# ($p[0].plugins | to_entries | map({key: .value.inputName, value: {flake: false,
# locked: {path: ("../plugins/" + .key), type: "path"},
# original: {path: ("../plugins/" + .key), type: "path"}, parent: []}}) | from_entries) as $pn
# | {nodes: ($pn + {"lazy-nvim": .nodes["lazy-nvim"],
# root: {inputs: ((($pn|keys) + ["lazy-nvim"]) | map({key: ., value: .}) | from_entries)}}),
# root: "root", version: 7}' flake.lock > $F/nvimx-lock/flake.lock
# Its lazy-nvim node is nvimx's own, so bumping the lazy-nvim input means regenerating it
# (the eval-time failure below says so): otherwise evaluation would fetch a second lazy.nvim.
{
pkgs,
nvimxLib,
lazy-nvim,
...
}:
let
inherit (pkgs) lib;
fx = ../fixtures/e2e-offline;
env = nvimxLib.makeEnv {
package = pkgs.neovim-unwrapped;
lockDir = fx + "/nvimx-lock";
};
readJson = p: builtins.fromJSON (builtins.readFile p);
fixtureLock = readJson (fx + "/nvimx-lock/flake.lock");
ownLock = readJson ../../flake.lock;
pluginsDb = readJson (fx + "/nvimx-lock/plugins.json");
# The tie between the fixture and what the check launches is a plain readFile of source files
# (never IFD), forced into the derivation through `failures` as in checks.genflake-golden, so
# that `nix eval ...aarch64-darwin...drvPath` catches it too.
pluginNodes = lib.mapAttrsToList (_: p: p.inputName) pluginsDb.plugins;
nonLazyNodes = lib.filterAttrs (
n: _: n != "root" && n != pluginsDb.lazyNvim.inputName
) fixtureLock.nodes;
failures =
lib.optional
(
(fixtureLock.nodes.${pluginsDb.lazyNvim.inputName}.locked or null)
!= ownLock.nodes.${pluginsDb.lazyNvim.inputName}.locked
)
"the fixture's lazy-nvim node differs from nvimx's own flake.lock: regenerate it (see the header of tests/checks/e2e-offline.nix), or evaluation fetches another lazy.nvim"
++
lib.optional
(
lib.sort (a: b: a < b) (builtins.attrNames fixtureLock.nodes.root.inputs)
!= lib.sort (a: b: a < b) (pluginNodes ++ [ pluginsDb.lazyNvim.inputName ])
)
"the fixture flake.lock root.inputs must be the plugins.json inputNames plus lazy-nvim, no stale or missing node"
++
lib.optional
(
!lib.all (
n: n.locked.type == "path" && !(n.locked ? narHash) && !(lib.hasPrefix "/" n.locked.path)
) (builtins.attrValues nonLazyNodes)
)
"every plugin node of the fixture flake.lock must be a relative path: node, or the check needs network";
# Any git call is a failure: the shim logs it and exits 1.
gitShim = pkgs.writeShellScriptBin "git" ''
echo "git $*" >> "$GIT_LOG"
exit 1
'';
in
assert failures == [ ] || throw (lib.concatStringsSep "\n" failures);
pkgs.runCommand "e2e-offline" {
nativeBuildInputs = [
pkgs.neovim-unwrapped
pkgs.jq
pkgs.git
pkgs.nix
pkgs.nixfmt
nvimxLib.lockApp
];
lazy = lazy-nvim;
lua = ../../lua/nvimx;
inherit fx gitShim;
inherit (env) farm wrapped;
probe = ../e2e-offline-test.lua;
# Step 3 runs a real nix on a private store. Switch to `if pkgs.stdenv.isLinux then "1" else ""`
# only if the first darwin CI run shows that store cannot be used there.
runLockLoop = "1";
} "source ${./scripts/e2e-offline.sh}"
8 changes: 4 additions & 4 deletions tests/checks/lock-app.nix
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# The lock app itself, end to end (#71): what nix/lib/lock-app.nix hands its `nix` calls, what
# the EXIT trap prints, and everything after them -- no other check gets past those calls,
# since recursive nix cannot run in a build. `nix` comes from the caller's PATH, so a stub
# the EXIT trap prints, and everything after them. `nix` comes from the caller's PATH, so a stub
# stands in: it answers the seed lookup (`eval`, #32) with nvimx's own lazy.nvim (not recorded
# in argv, so it is not counted as a call), records argv and cwd, fails on the NVIMX_STUB_FAIL_AT-th
# call (default 1) when NVIMX_STUB_FAIL is set (#81: the later call sites need a failure of their
# own), can corrupt the update snapshot (NVIMX_STUB_CORRUPT_BEFORE) or wait for a pipe reader to
# be gone before failing (NVIMX_STUB_WAIT_FOR), and otherwise writes a fixture
# flake.lock (the convergence pass reads it right after). That the real nix accepts `path:.`
# stays a manual check.
# flake.lock (the convergence pass reads it right after). The stub is for what a real nix cannot
# be made to do here (failure exit codes, signals); real-nix behaviour -- accepting `path:.`,
# convergence against a real lock, stale-node removal -- is checks.e2e-offline step 3.
{
pkgs,
nvimxLib,
Expand Down
137 changes: 137 additions & 0 deletions tests/checks/scripts/e2e-offline.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# shellcheck shell=bash disable=SC2154
# Body of checks.e2e-offline, sourced by the runCommand in ../e2e-offline.nix.
# $fx $lazy $lua $farm $wrapped $gitShim $probe $runLockLoop come from that derivation's env.
export HOME=$TMPDIR
# nix refuses a store root with a symlink anywhere in its parents, and darwin's /tmp is one,
# so every path step 3 hands to nix (store, remotes, configs) lives under the resolved TMPDIR.
base=$(realpath "$TMPDIR")

# 1. Fixture integrity (extract -> resolve -> genflake). Steps 2 and 3 are only meaningful if
# the committed lock is what the pipeline makes of this init.lua today. The per-stage goldens
# (extractor-snapshot, resolve-golden, genflake-golden) use other fixtures and launch nothing.
# No --lock / --prev: that is a first pass, so the pin stays unfrozen and nothing goes online.
sb=$base/sandbox
mkdir -p "$sb"/config "$sb"/data/nvim/lazy "$sb"/state "$sb"/cache gen
ln -s "$fx" "$sb"/config/nvim
ln -s "$lazy" "$sb"/data/nvim/lazy/lazy.nvim
# timeout + closed stdin: a config that never reaches setup would otherwise hang the build (#3).
env \
XDG_CONFIG_HOME="$sb"/config \
XDG_DATA_HOME="$sb"/data \
XDG_STATE_HOME="$sb"/state \
XDG_CACHE_HOME="$sb"/cache \
NVIMX_LAZY_SEED="$lazy" \
NVIMX_OUT="$sb"/raw-spec.json \
timeout 120 nvim --headless --cmd "luafile $lua/extract.lua" </dev/null
nvim -l "$lua"/resolve.lua "$sb"/raw-spec.json gen/plugins.json --lazy "$lazy"
nvim -l "$lua"/genflake.lua gen/plugins.json gen/flake.nix
nixfmt gen/flake.nix
cmp gen/plugins.json "$fx"/nvimx-lock/plugins.json
cmp gen/flake.nix "$fx"/nvimx-lock/flake.nix

# 2. The env is already built (a derivation input). Launch the wrapped nvim with the user's
# init.lua laid out as the hm module / packages.demo do, with a git shim as the only git on a
# minimal PATH: every plugin must come from the store without a single git call.
x=$base/run
mkdir -p "$x"/config "$x"/data/nvim/lazy "$x"/state "$x"/cache
ln -s "$fx" "$x"/config/nvim
ln -s "$farm"/lazy.nvim "$x"/data/nvim/lazy/lazy.nvim
git_log=$base/git.log
: >"$git_log"
coreutils_bin=$(dirname "$(command -v timeout)")
# Closed stdin and a timeout, as for the extractor above: the guard against a hung headless nvim.
# `|| rc=$?` so that the failure message below is reached under errexit.
rc=0
env \
PATH="$gitShim/bin:$coreutils_bin" \
GIT_LOG="$git_log" \
FARM="$farm" \
PROBE_OK="$base"/probe-ok \
XDG_CONFIG_HOME="$x"/config \
XDG_DATA_HOME="$x"/data \
XDG_STATE_HOME="$x"/state \
XDG_CACHE_HOME="$x"/cache \
timeout 120 "$wrapped"/bin/nvim --headless -c "luafile $probe" </dev/null || rc=$?
if [ "$rc" -ne 0 ] || [ ! -f "$base"/probe-ok ]; then
echo "the wrapped nvim did not start cleanly (exit $rc)" >&2
exit 1
fi
if [ -s "$git_log" ]; then
echo "git was called at runtime:" >&2
cat "$git_log" >&2
exit 1
fi

# 3. A real `nix flake lock` through nvimx-lock, on file:// remotes and a private store (the build
# sandbox has no daemon). lock-app only proves this against a stub nix.
if [ -n "$runLockLoop" ]; then
cp -r "$fx" "$base"/src
chmod -R u+w "$base"/src
for d in "$base"/src/plugins/*; do
r=$base/remotes/nvimx-e2e/$(basename "$d")
mkdir -p "$r"
cp -r "$d"/. "$r"/
git -C "$r" init -q -b main
git -C "$r" add -A
git -C "$r" -c user.name=nvimx -c user.email=nvimx@example.com commit -q -m init
done
mkdir -p "$base"/cfg "$base"/out
# url_format is the one string that tells the two halves apart (see the fixture's init.lua).
sed "s#https://github.com/%s.git#file://$base/remotes/%s#" "$fx"/init.lua >"$base"/cfg/init.lua
grep -q "file://$base/remotes" "$base"/cfg/init.lua
# lazy.nvim is a file:// remote too (spec added to this copy of init.lua only): the lock's
# `nix eval` seed lookup (#32) fetches the seeded github node, which has no network here. The
# copy must stay byte-identical to $lazy: nix eval on the private store returns a logical
# /nix/store/...-source path that only exists because it equals the $lazy build input.
r=$base/remotes/folke/lazy.nvim
mkdir -p "$r"
cp -r "$lazy"/. "$r"/
chmod -R u+w "$r"
git -C "$r" init -q -b main
git -C "$r" add -A
git -C "$r" -c user.name=nvimx -c user.email=nvimx@example.com commit -q -m init
sed -i 's#^require("lazy").setup({$#&\n { "folke/lazy.nvim" },#' "$base"/cfg/init.lua
grep -q 'folke/lazy.nvim" }' "$base"/cfg/init.lua
export NIX_CONFIG="experimental-features = nix-command flakes
store = $base/store
flake-registry ="
lock() { nvimx-lock --config "$base"/cfg --out "$base"/out 2>"$base"/lock.log || {
cat "$base"/lock.log >&2
return 1
}; }

# 3a. The first run freezes the pin: resolvedRef, flake.lock and flake.nix agree on one rev.
lock
grep -q '^nvimx-lock: freezing pinned plugins$' "$base"/lock.log
rev=$(git -C "$base"/remotes/nvimx-e2e/alpha.nvim rev-parse HEAD)
[ "$(jq -r '.plugins["alpha.nvim"].resolvedRef' "$base"/out/plugins.json)" = "$rev" ]
[ "$(jq -r '.nodes["alpha-nvim"].locked.rev' "$base"/out/flake.lock)" = "$rev" ]
grep -q "?rev=$rev" "$base"/out/flake.nix

# 3b. Same plugin graph as the committed lock; only where the sources live differs.
proj='{plugins: (.plugins | map_values(del(.source, .resolvedRef))), lazyNvim: .lazyNvim.inputName, localPlugins}'
[ "$(jq -S "$proj" "$base"/out/plugins.json)" = "$(jq -S "$proj" "$fx"/nvimx-lock/plugins.json)" ]

# 3c. A second run converges: byte-identical output, and nothing left to freeze.
cp -r "$base"/out "$base"/out1
lock
diff -r "$base"/out1 "$base"/out
if grep -q 'freezing' "$base"/lock.log; then
echo "the second run froze something again" >&2
exit 1
fi

# 3d. Dropping a spec drops its node (and its dependency's) from flake.lock, via real nix.
sed -i '/nvimx-e2e\/beta.nvim/d' "$base"/cfg/init.lua
lock
for n in beta-nvim gamma-nvim; do
jq -e --arg n "$n" '(.nodes | has($n) | not) and (.nodes.root.inputs | has($n) | not)' "$base"/out/flake.lock >/dev/null
jq -e --arg n "$n" '[.plugins[].inputName] | index($n) | not' "$base"/out/plugins.json >/dev/null
if grep -q "$n" "$base"/out/flake.nix; then
echo "$n is still in flake.nix" >&2
exit 1
fi
done
[ "$(jq -S '.nodes["alpha-nvim"]' "$base"/out/flake.lock)" = "$(jq -S '.nodes["alpha-nvim"]' "$base"/out1/flake.lock)" ]
fi
touch "$out"
48 changes: 48 additions & 0 deletions tests/e2e-offline-test.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
-- Driver of checks.e2e-offline step 2, run inside the wrapped nvim as
-- `nvim --headless -c "luafile <this>"` with the user's init.lua already executed. The farm
-- is read from $FARM and the success marker is written to $PROBE_OK: an error in a -c command
-- alone does not make headless nvim exit non-zero, so the shell checks the marker too.
local function run()
local farm = assert(os.getenv("FARM"), "FARM unset")
assert(vim.v.errmsg == "", "startup raised: " .. vim.v.errmsg)
assert(vim.g.nvimx_e2e_alpha == true, "the startup plugin was not sourced")

local plugins = require("lazy.core.config").plugins
local names = vim.tbl_keys(plugins)
table.sort(names)
assert(
vim.deep_equal(names, { "alpha.nvim", "beta.nvim", "gamma.nvim", "lazy.nvim" }),
"unexpected plugin set: " .. table.concat(names, ",")
)

-- beta is lazy (cmd): not loaded until :BetaHi, which also pulls gamma in as a dependency.
assert(not plugins["beta.nvim"]._.loaded, "beta.nvim loaded before :BetaHi")
vim.cmd("BetaHi")
assert(vim.g.nvimx_e2e_beta == 42, "BetaHi did not reach gamma through the dependency")

local data = vim.fn.stdpath("data")
for name, p in pairs(plugins) do
assert(p._.loaded, name .. " is not loaded")
local real = vim.uv.fs_realpath(p.dir)
assert(real and vim.startswith(real, "/nix/store/"), name .. " is not in the store: " .. tostring(real))
if name == "lazy.nvim" then
-- lazy's own entry is not a spec: its dir is the bootstrap symlink into the farm.
assert(real == vim.uv.fs_realpath(farm .. "/lazy.nvim"), "lazy.nvim does not come from the farm")
else
assert(p._.is_local, name .. " is not local")
assert(p.dir == farm .. "/" .. name, name .. " dir is " .. tostring(p.dir))
assert(not vim.uv.fs_stat(data .. "/lazy/" .. name), name .. " was cloned")
end
end
end

local ok, err = pcall(run)
if ok then
local f = assert(io.open(assert(os.getenv("PROBE_OK")), "w"))
f:write("ok\n")
f:close()
vim.cmd("qa!")
else
io.stderr:write("e2e-offline probe failed: " .. tostring(err) .. "\n")
vim.cmd("cquit 1")
end
17 changes: 17 additions & 0 deletions tests/fixtures/e2e-offline/init.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- Standard lazy bootstrap with its clone guard: under nvimx stdpath("data")/lazy/lazy.nvim is a
-- symlink into the farm, so a clone here would mean the farm was not wired up (checks.e2e-offline
-- puts a git shim on PATH that fails the check if it is ever called).
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not (vim.uv or vim.loop).fs_stat(lazypath) then
local lazyrepo = "https://github.com/folke/lazy.nvim.git"
vim.fn.system({ "git", "clone", "--filter=blob:none", lazyrepo, lazypath })
end
vim.opt.rtp:prepend(lazypath)

-- nvimx-e2e is a fake owner and is never fetched. url_format is lazy's default, spelled out so
-- that checks.e2e-offline can swap exactly this string for file:// remotes. Keep each top-level
-- spec on one line: the check deletes beta with a line-based sed.
require("lazy").setup({
{ "nvimx-e2e/alpha.nvim", lazy = false, pin = true },
{ "nvimx-e2e/beta.nvim", cmd = "BetaHi", dependencies = { "nvimx-e2e/gamma.nvim" } },
}, { git = { url_format = "https://github.com/%s.git" } })
Loading
Loading