test: VM smoke and Lightning regtest suites

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-28 06:48:38 +02:00
commit 7f4a71b98d
4 changed files with 358 additions and 0 deletions

80
tests/refactor-smoke.nix Normal file
View file

@ -0,0 +1,80 @@
# refactor-smoke — headless VM boot test for autonomous refactor runs
#
# The "closed environment" for aggressive refactors is the *worktree* at
# ~/nixos-refactor/ plus the lack of sudo (so `omni-rebuild switch`
# can't activate on bohm). This test gives Claude an objective
# success/fail signal it can iterate against without a human in the loop.
#
# Run from a worktree of /etc/nixos:
# nix build .#checks.x86_64-linux.refactor-smoke -L
#
# Or as part of the full check suite:
# nix flake check --keep-going -L
#
# Extension points (the starter is intentionally minimal):
# - Add `imports` to `nodes.machine` as you bring more modules under
# test. Mind: graphical bits (Hyprland, greetd) are skipped — they
# need a real display and aren't easy to assert against in QEMU.
# - Add assertions to `testScript` as you pin more behavior. Useful
# idioms:
# machine.succeed("systemctl is-active <unit>.service")
# machine.succeed("test -f /etc/<path>")
# machine.succeed("grep -q '<line>' /etc/<file>")
{
nixpkgs,
system ? "x86_64-linux",
...
}:
let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
};
in
pkgs.testers.runNixOSTest {
name = "omni-refactor-smoke";
nodes.machine =
{
config,
pkgs,
lib,
...
}:
{
# Match the real machine's primary user so home-managed paths
# resolve identically when the test grows to exercise them.
users.users.padreug = {
isNormalUser = true;
uid = 1000;
extraGroups = [ "wheel" ];
};
# Tools the refactor surface should always ship. Mirror this list
# to whatever your CLAUDE.md / dev-env claims is installed.
environment.systemPackages = with pkgs; [
git
pnpm
neovim
];
system.stateVersion = "24.11";
};
testScript = ''
machine.start()
machine.wait_for_unit("multi-user.target")
# Boot reached multi-user.
machine.succeed("uptime")
# Primary user exists with expected uid.
machine.succeed("test $(id -u padreug) -eq 1000")
# Key tools functional, not just present on PATH.
machine.succeed("git --version")
machine.succeed("pnpm --version")
machine.succeed("nvim --version | head -1")
'';
}

109
tests/regtest-core.nix Normal file
View file

@ -0,0 +1,109 @@
# regtest-core — NixOS-native Lightning regtest integration test.
#
# A reproducible, hermetic replacement for the core of the docker
# `legend-regtest-enviroment` stack (aiolabs/omnixient#27): one VM running
# bitcoind(regtest) + Core Lightning + LND (the bitcoin pack's nix-bitcoin
# modules), driving a real payment flow and asserting it settles.
#
# Both LN nodes are co-located on one node and share the local bitcoind;
# lnd's p2p port is moved to 9736 so it doesn't clash with clightning's
# 9735. CLIs are run as the nix-bitcoin `operator` user, matching how
# nix-bitcoin's own test suite drives them.
#
# The node config is shared with the interactive dev VM via
# tests/regtest-node.nix so the two can never drift.
#
# Run headless:
# nix build .#checks.x86_64-linux.regtest-core -L
# Interactive driver (poke the node by hand):
# nix build .#checks.x86_64-linux.regtest-core.driverInteractive
# ./result/bin/nixos-test-driver
# Friendlier interactive shell (SSH, copy/paste, operator user, helpers):
# nix run .#regtest (see docs/regtest.md)
{
nixpkgs,
system ? "x86_64-linux",
nix-bitcoin,
...
}:
let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
};
in
pkgs.testers.runNixOSTest {
name = "omni-regtest-core";
nodes.regtest = {
imports = [
nix-bitcoin.nixosModules.default
./regtest-node.nix # shared bitcoind + CLN + LND config
];
};
testScript = ''
def op(cmd):
"""Run a command as the nix-bitcoin operator and return stdout."""
return regtest.succeed(f"runuser -u operator -- {cmd}")
regtest.start()
# --- services up ---
regtest.wait_for_unit("bitcoind.service")
regtest.wait_for_unit("clightning.service")
regtest.wait_for_unit("lnd.service")
# CLIs answer (clightning/lnd take a moment past unit-active to be RPC-ready).
regtest.wait_until_succeeds("runuser -u operator -- lightning-cli getinfo", timeout=120)
regtest.wait_until_succeeds("runuser -u operator -- lncli getinfo", timeout=120)
# --- both LN nodes synced to the pre-mined regtest chain ---
regtest.wait_until_succeeds(
f"[[ $(runuser -u operator -- lightning-cli getinfo | jq -M .blockheight) == {110} ]]",
timeout=120,
)
regtest.wait_until_succeeds(
f"[[ $(runuser -u operator -- lncli getinfo | jq -M .block_height) == {110} ]]",
timeout=120,
)
# --- fund LND's on-chain wallet from the bitcoind test wallet ---
lnd_addr = op("lncli newaddress p2wkh | jq -r .address").strip()
op(f"bitcoin-cli -rpcwallet=test sendtoaddress {lnd_addr} 5")
mine_addr = op("bitcoin-cli -rpcwallet=test getnewaddress").strip()
op(f"bitcoin-cli -rpcwallet=test generatetoaddress 6 {mine_addr}")
regtest.wait_until_succeeds(
"[[ $(runuser -u operator -- lncli walletbalance | jq -M '.confirmed_balance | tonumber') -gt 0 ]]",
timeout=120,
)
# --- connect LND -> CLN and open a channel ---
cln_id = op("lightning-cli getinfo | jq -r .id").strip()
op(f"lncli connect {cln_id}@127.0.0.1:9735")
op(f"lncli openchannel --node_key={cln_id} --local_amt=1000000") # 0.01 BTC
# confirm the channel and wait for it to go active on the LND side
op(f"bitcoin-cli -rpcwallet=test generatetoaddress 6 {mine_addr}")
regtest.wait_until_succeeds(
"[[ $(runuser -u operator -- lncli listchannels | jq '[.channels[] | select(.active)] | length') -ge 1 ]]",
timeout=240,
)
# --- CLN issues an invoice, LND pays it ---
bolt11 = op(
"lightning-cli invoice 100000000 regtest-core-test 'regtest-core payment' | jq -r .bolt11"
).strip() # 100000 sat = 100,000,000 msat
op(f"lncli payinvoice --force {bolt11}")
# --- assert CLN saw the invoice settle ---
regtest.wait_until_succeeds(
"[[ $(runuser -u operator -- lightning-cli listinvoices regtest-core-test "
"| jq -r '.invoices[0].status') == paid ]]",
timeout=180,
)
print("regtest-core: LND -> CLN channel opened and invoice paid ")
'';
}

View file

@ -0,0 +1,110 @@
# regtest-interactive — turns the shared regtest stack into a friendly,
# SSH-able dev VM (driven by `nix run .#regtest`).
#
# Layered on top of tests/regtest-node.nix + nix-bitcoin (see flake.nix).
# Unlike the CI test's QEMU console / Python REPL, this gives you a real
# terminal: copy/paste, scrollback, no log spam, landed straight in the
# `operator` user with the CLIs preconfigured.
{
config,
pkgs,
lib,
settings ? {
sshKeys = [ ];
},
...
}:
let
# Fund LND on-chain and open an LND -> CLN channel, so you start with a
# working channel instead of building it by hand every session. Runs as
# operator (the CLIs are on its PATH); waits for the daemons first.
regtest-fund = pkgs.writeShellScriptBin "regtest-fund" ''
set -euo pipefail
echo " waiting for the lightning daemons..."
until lncli getinfo >/dev/null 2>&1 && lightning-cli getinfo >/dev/null 2>&1; do
sleep 2
done
echo "💰 funding LND on-chain..."
addr=$(lncli newaddress p2wkh | jq -r .address)
bitcoin-cli -rpcwallet=test sendtoaddress "$addr" 5 >/dev/null
mine=$(bitcoin-cli -rpcwallet=test getnewaddress)
bitcoin-cli -rpcwallet=test generatetoaddress 6 "$mine" >/dev/null
until [ "$(lncli walletbalance | jq -M '.confirmed_balance | tonumber')" -gt 0 ]; do
sleep 2
done
echo "🔗 opening LND -> CLN channel..."
cln=$(lightning-cli getinfo | jq -r .id)
lncli connect "$cln@127.0.0.1:9735" >/dev/null 2>&1 || true
lncli openchannel --node_key="$cln" --local_amt=1000000 >/dev/null
bitcoin-cli -rpcwallet=test generatetoaddress 6 "$mine" >/dev/null
echo " waiting for the channel to activate..."
until [ "$(lncli listchannels | jq '[.channels[] | select(.active)] | length')" -ge 1 ]; do
bitcoin-cli -rpcwallet=test generatetoaddress 1 "$mine" >/dev/null
sleep 2
done
echo " channel active. Try:"
echo " bolt11=\$(cln invoice 50000000 demo demo | jq -r .bolt11)"
echo " lncli payinvoice --force \$bolt11"
'';
in
{
networking.hostName = "regtest";
# Throwaway VM — value is irrelevant, set only to silence the eval warning.
system.stateVersion = "24.11";
# SSH is the whole point — a clean terminal you can paste into.
services.openssh = {
enable = true;
settings.PasswordAuthentication = true;
};
# Land as `operator` (the nix-bitcoin user the CLIs are wired for).
# Simple shared password for a throwaway local VM with fake coins; also
# accept the adopter's SSH keys if settings.nix has any.
users.users.operator = {
password = "password"; # pragma: allowlist secret
extraGroups = [ "wheel" ];
openssh.authorizedKeys.keys = settings.sshKeys or [ ];
};
# Auto-login operator on the serial console too (if you skip SSH).
services.getty.autologinUser = "operator";
# Convenience aliases. `bitcoin-cli` already auto-selects the single
# loaded `test` wallet, but `btc` makes it explicit and short. We avoid
# aliasing `ln` (that's coreutils) — use the real `lncli`.
environment.shellAliases = {
btc = "bitcoin-cli -rpcwallet=test";
cln = "lightning-cli";
};
environment.systemPackages = [ regtest-fund ];
users.motd = ''
Omnixient regtest dev VM
user: operator password: password (sudo enabled)
aliases: btc bitcoin-cli -rpcwallet=test
cln lightning-cli lncli LND CLI
helper: regtest-fund fund LND + open an LNDCLN channel
nodes: bitcoind(regtest) CLN :9735 LND :9736
note: the stack pre-mines 110 blocks on boot give it a few
seconds, then run `regtest-fund`.
quit: `exit` (the VM is discarded)
'';
# Headless + host:2222 -> guest:22 so `nix run .#regtest` can SSH in.
virtualisation.graphics = false;
virtualisation.forwardPorts = [
{
from = "host";
host.port = 2222;
guest.port = 22;
}
];
}

59
tests/regtest-node.nix Normal file
View file

@ -0,0 +1,59 @@
# regtest-node — shared bitcoind(regtest) + Core Lightning + LND config.
#
# The single source of truth for the regtest stack, imported by BOTH:
# - the CI test → tests/regtest-core.nix
# - the interactive VM → tests/regtest-interactive.nix (via flake.nix)
# so the two can never drift.
#
# Consumers must ALSO import `nix-bitcoin.nixosModules.default` — it needs
# the flake input, which a plain module can't reach on its own.
#
# Both LN nodes are co-located and share the local bitcoind; lnd's p2p
# port is moved to 9736 so it doesn't clash with clightning's 9735. CLIs
# run as the nix-bitcoin `operator` user.
{
config,
pkgs,
lib,
...
}:
{
nix-bitcoin.generateSecrets = true;
nix-bitcoin.operator.enable = true;
services.bitcoind = {
enable = true;
regtest = true;
# regtest has no fee estimation, so sendtoaddress/funding need a
# fallback fee. nix-bitcoin appends extraConfig inside the [regtest]
# section, so this applies to the regtest network.
extraConfig = "fallbackfee=0.0002";
};
# Pre-mine a funded "test" wallet on bitcoind startup. 110 > 100 so the
# coinbase outputs are mature/spendable.
# NOTE: keep this height in sync with the assertions in
# tests/regtest-core.nix (it checks the nodes reach block 110).
systemd.services.bitcoind.postStart = lib.mkAfter ''
cli=${config.services.bitcoind.cli}/bin/bitcoin-cli
if ! $cli listwallets | ${pkgs.jq}/bin/jq -e 'index("test")' >/dev/null; then
"$cli" -named createwallet wallet_name=test load_on_startup=true
addr=$("$cli" -rpcwallet=test getnewaddress)
"$cli" generatetoaddress 110 "$addr"
fi
'';
services.clightning.enable = true; # p2p :9735
services.lnd = {
enable = true;
port = 9736; # avoid clashing with clightning's 9735
};
# jq is used by the test script and the interactive helpers.
environment.systemPackages = [ pkgs.jq ];
# Headroom for bitcoind + two lightning daemons in one VM.
virtualisation.cores = 2;
virtualisation.memorySize = 3072;
virtualisation.diskSize = 8192;
}