banger/internal/cli/cli_test.go
Thales Maciel 5f89c07fc0
Fix vm run guest repo path and add vm acp bridge
Normalize repo-backed guest checkouts to /root/repo so vm run, attach, and
follow-on guest tooling stop depending on the source repository name.

Add `banger vm acp [--cwd] <vm>` as an SSH stdio bridge to guest `opencode acp`,
defaulting to /root/repo when that checkout exists and falling back to /root.
Update the README and CLI coverage around the fixed guest path and ACP command.

Validation: go test ./internal/cli, go test ./..., make build.
2026-04-01 19:42:00 -03:00

2083 lines
67 KiB
Go

package cli
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"banger/internal/api"
"banger/internal/buildinfo"
"banger/internal/model"
"banger/internal/system"
"banger/internal/toolingplan"
)
func TestNewBangerCommandHasExpectedSubcommands(t *testing.T) {
cmd := NewBangerCommand()
names := []string{}
for _, sub := range cmd.Commands() {
names = append(names, sub.Name())
}
want := []string{"daemon", "doctor", "image", "internal", "ps", "version", "vm"}
if !reflect.DeepEqual(names, want) {
t.Fatalf("subcommands = %v, want %v", names, want)
}
}
func TestVersionCommandPrintsBuildInfo(t *testing.T) {
cmd := NewBangerCommand()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stdout)
cmd.SetArgs([]string{"version"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
info := buildinfo.Current()
output := stdout.String()
for _, want := range []string{
"version: " + info.Version,
"commit: " + info.Commit,
"built_at: " + info.BuiltAt,
} {
if !strings.Contains(output, want) {
t.Fatalf("output = %q, want %q", output, want)
}
}
}
func TestLegacyRemovedCommandIsRejected(t *testing.T) {
cmd := NewBangerCommand()
cmd.SetArgs([]string{"tui"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "unknown command \"tui\"") {
t.Fatalf("Execute() error = %v, want unknown legacy command", err)
}
}
func TestDoctorCommandPrintsReportAndFailsOnHardFailures(t *testing.T) {
original := doctorFunc
t.Cleanup(func() {
doctorFunc = original
})
doctorFunc = func(context.Context) (system.Report, error) {
return system.Report{
Checks: []system.CheckResult{
{Name: "runtime bundle", Status: system.CheckStatusPass, Details: []string{"runtime dir /tmp/runtime"}},
{Name: "feature nat", Status: system.CheckStatusFail, Details: []string{"missing iptables"}},
},
}, nil
}
cmd := NewBangerCommand()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stdout)
cmd.SetArgs([]string{"doctor"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "doctor found failing checks") {
t.Fatalf("Execute() error = %v, want doctor failure", err)
}
output := stdout.String()
if !strings.Contains(output, "PASS\truntime bundle") {
t.Fatalf("output = %q, want runtime bundle pass", output)
}
if !strings.Contains(output, "FAIL\tfeature nat") {
t.Fatalf("output = %q, want feature nat fail", output)
}
}
func TestDoctorCommandReturnsUnderlyingError(t *testing.T) {
original := doctorFunc
t.Cleanup(func() {
doctorFunc = original
})
doctorFunc = func(context.Context) (system.Report, error) {
return system.Report{}, errors.New("load failed")
}
cmd := NewBangerCommand()
cmd.SetArgs([]string{"doctor"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "load failed") {
t.Fatalf("Execute() error = %v, want load failed", err)
}
}
func TestInternalNATFlagsExist(t *testing.T) {
root := NewBangerCommand()
internal, _, err := root.Find([]string{"internal"})
if err != nil {
t.Fatalf("find internal: %v", err)
}
nat, _, err := internal.Find([]string{"nat"})
if err != nil {
t.Fatalf("find nat: %v", err)
}
up, _, err := nat.Find([]string{"up"})
if err != nil {
t.Fatalf("find nat up: %v", err)
}
for _, flagName := range []string{"guest-ip", "tap"} {
if up.Flags().Lookup(flagName) == nil {
t.Fatalf("missing flag %q", flagName)
}
}
}
func TestInternalPackagesCommandSupportsAlpine(t *testing.T) {
cmd := NewBangerCommand()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetArgs([]string{"internal", "packages", "alpine"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute(): %v", err)
}
output := stdout.String()
for _, want := range []string{"alpine-base", "docker", "libgcc", "libstdc++", "mkinitfs", "openssh"} {
if !strings.Contains(output, want+"\n") {
t.Fatalf("output = %q, want package %q", output, want)
}
}
}
func TestPSAndVMListAliasesAndFlagsExist(t *testing.T) {
root := NewBangerCommand()
ps, _, err := root.Find([]string{"ps"})
if err != nil {
t.Fatalf("find ps: %v", err)
}
for _, flagName := range []string{"all", "latest", "quiet"} {
if ps.Flags().Lookup(flagName) == nil {
t.Fatalf("missing ps flag %q", flagName)
}
}
vm, _, err := root.Find([]string{"vm"})
if err != nil {
t.Fatalf("find vm: %v", err)
}
list, _, err := vm.Find([]string{"list"})
if err != nil {
t.Fatalf("find list: %v", err)
}
if _, _, err := vm.Find([]string{"ls"}); err != nil {
t.Fatalf("find ls alias: %v", err)
}
if _, _, err := vm.Find([]string{"ps"}); err != nil {
t.Fatalf("find ps alias: %v", err)
}
for _, flagName := range []string{"all", "latest", "quiet"} {
if list.Flags().Lookup(flagName) == nil {
t.Fatalf("missing vm list flag %q", flagName)
}
}
}
func TestPSCommandRejectsArgs(t *testing.T) {
cmd := NewBangerCommand()
cmd.SetArgs([]string{"ps", "extra"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "usage: banger ps") {
t.Fatalf("Execute() error = %v, want ps usage error", err)
}
}
func TestVMCreateFlagsExist(t *testing.T) {
root := NewBangerCommand()
vm, _, err := root.Find([]string{"vm"})
if err != nil {
t.Fatalf("find vm: %v", err)
}
create, _, err := vm.Find([]string{"create"})
if err != nil {
t.Fatalf("find create: %v", err)
}
for _, flagName := range []string{"name", "image", "vcpu", "memory", "system-overlay-size", "disk-size", "nat", "no-start"} {
if create.Flags().Lookup(flagName) == nil {
t.Fatalf("missing flag %q", flagName)
}
}
}
func TestVMRunFlagsExist(t *testing.T) {
root := NewBangerCommand()
vm, _, err := root.Find([]string{"vm"})
if err != nil {
t.Fatalf("find vm: %v", err)
}
run, _, err := vm.Find([]string{"run"})
if err != nil {
t.Fatalf("find run: %v", err)
}
for _, flagName := range []string{"name", "image", "vcpu", "memory", "system-overlay-size", "disk-size", "nat", "branch", "from"} {
if run.Flags().Lookup(flagName) == nil {
t.Fatalf("missing flag %q", flagName)
}
}
if run.Flags().Lookup("no-start") != nil {
t.Fatal("vm run should not expose --no-start")
}
}
func TestVMACPFlagsExist(t *testing.T) {
root := NewBangerCommand()
vm, _, err := root.Find([]string{"vm"})
if err != nil {
t.Fatalf("find vm: %v", err)
}
acp, _, err := vm.Find([]string{"acp"})
if err != nil {
t.Fatalf("find acp: %v", err)
}
if acp.Flags().Lookup("cwd") == nil {
t.Fatal("missing flag \"cwd\"")
}
}
func TestVMCreateFlagsShowStaticDefaults(t *testing.T) {
root := NewBangerCommand()
vm, _, err := root.Find([]string{"vm"})
if err != nil {
t.Fatalf("find vm: %v", err)
}
create, _, err := vm.Find([]string{"create"})
if err != nil {
t.Fatalf("find create: %v", err)
}
if got := create.Flags().Lookup("vcpu").DefValue; got != fmt.Sprintf("%d", model.DefaultVCPUCount) {
t.Fatalf("vcpu default = %q, want %d", got, model.DefaultVCPUCount)
}
if got := create.Flags().Lookup("memory").DefValue; got != fmt.Sprintf("%d", model.DefaultMemoryMiB) {
t.Fatalf("memory default = %q, want %d", got, model.DefaultMemoryMiB)
}
if got := create.Flags().Lookup("system-overlay-size").DefValue; got != model.FormatSizeBytes(model.DefaultSystemOverlaySize) {
t.Fatalf("system-overlay-size default = %q, want %q", got, model.FormatSizeBytes(model.DefaultSystemOverlaySize))
}
if got := create.Flags().Lookup("disk-size").DefValue; got != model.FormatSizeBytes(model.DefaultWorkDiskSize) {
t.Fatalf("disk-size default = %q, want %q", got, model.FormatSizeBytes(model.DefaultWorkDiskSize))
}
}
func TestVMRunRejectsFromWithoutBranch(t *testing.T) {
cmd := NewBangerCommand()
cmd.SetArgs([]string{"vm", "run", "--from", "HEAD"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "--from requires --branch") {
t.Fatalf("Execute() error = %v, want --from requires --branch", err)
}
}
func TestImageRegisterFlagsExist(t *testing.T) {
root := NewBangerCommand()
image, _, err := root.Find([]string{"image"})
if err != nil {
t.Fatalf("find image: %v", err)
}
register, _, err := image.Find([]string{"register"})
if err != nil {
t.Fatalf("find register: %v", err)
}
for _, flagName := range []string{"name", "rootfs", "work-seed", "kernel", "initrd", "modules", "docker"} {
if register.Flags().Lookup(flagName) == nil {
t.Fatalf("missing flag %q", flagName)
}
}
}
func TestImagePromoteCommandExists(t *testing.T) {
root := NewBangerCommand()
image, _, err := root.Find([]string{"image"})
if err != nil {
t.Fatalf("find image: %v", err)
}
if _, _, err := image.Find([]string{"promote"}); err != nil {
t.Fatalf("find promote: %v", err)
}
}
func TestVMKillFlagsExist(t *testing.T) {
root := NewBangerCommand()
vm, _, err := root.Find([]string{"vm"})
if err != nil {
t.Fatalf("find vm: %v", err)
}
kill, _, err := vm.Find([]string{"kill"})
if err != nil {
t.Fatalf("find kill: %v", err)
}
if kill.Flags().Lookup("signal") == nil {
t.Fatal("missing signal flag")
}
}
func TestVMPortsCommandExists(t *testing.T) {
root := NewBangerCommand()
vm, _, err := root.Find([]string{"vm"})
if err != nil {
t.Fatalf("find vm: %v", err)
}
if _, _, err := vm.Find([]string{"ports"}); err != nil {
t.Fatalf("find ports: %v", err)
}
}
func TestVMPortsCommandRejectsMultipleRefs(t *testing.T) {
cmd := NewBangerCommand()
cmd.SetArgs([]string{"vm", "ports", "alpha", "beta"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "usage: banger vm ports <id-or-name>") {
t.Fatalf("Execute() error = %v, want single-vm usage error", err)
}
}
func TestVMSetParamsFromFlags(t *testing.T) {
params, err := vmSetParamsFromFlags("devbox", 4, 2048, "16G", true, false)
if err != nil {
t.Fatalf("vmSetParamsFromFlags: %v", err)
}
if params.IDOrName != "devbox" || params.VCPUCount == nil || *params.VCPUCount != 4 {
t.Fatalf("unexpected params: %+v", params)
}
if params.MemoryMiB == nil || *params.MemoryMiB != 2048 {
t.Fatalf("unexpected memory: %+v", params)
}
if params.WorkDiskSize != "16G" {
t.Fatalf("unexpected disk size: %+v", params)
}
if params.NATEnabled == nil || !*params.NATEnabled {
t.Fatalf("unexpected nat value: %+v", params)
}
}
func TestVMCreateParamsFromFlagsOmitsStaticDefaultsWhenFlagsAreUnchanged(t *testing.T) {
cmd := NewBangerCommand()
vm, _, err := cmd.Find([]string{"vm"})
if err != nil {
t.Fatalf("find vm: %v", err)
}
create, _, err := vm.Find([]string{"create"})
if err != nil {
t.Fatalf("find create: %v", err)
}
params, err := vmCreateParamsFromFlags(
create,
"devbox",
"default",
model.DefaultVCPUCount,
model.DefaultMemoryMiB,
model.FormatSizeBytes(model.DefaultSystemOverlaySize),
model.FormatSizeBytes(model.DefaultWorkDiskSize),
false,
false,
)
if err != nil {
t.Fatalf("vmCreateParamsFromFlags: %v", err)
}
if params.VCPUCount != nil || params.MemoryMiB != nil || params.SystemOverlaySize != "" || params.WorkDiskSize != "" {
t.Fatalf("expected unchanged defaults to stay omitted: %+v", params)
}
}
func TestVMCreateParamsFromFlagsIncludesChangedDiskFlags(t *testing.T) {
cmd := NewBangerCommand()
vm, _, err := cmd.Find([]string{"vm"})
if err != nil {
t.Fatalf("find vm: %v", err)
}
create, _, err := vm.Find([]string{"create"})
if err != nil {
t.Fatalf("find create: %v", err)
}
if err := create.Flags().Set("system-overlay-size", "16G"); err != nil {
t.Fatalf("set system-overlay-size flag: %v", err)
}
if err := create.Flags().Set("disk-size", "32G"); err != nil {
t.Fatalf("set disk-size flag: %v", err)
}
params, err := vmCreateParamsFromFlags(create, "devbox", "default", model.DefaultVCPUCount, model.DefaultMemoryMiB, "16G", "32G", false, false)
if err != nil {
t.Fatalf("vmCreateParamsFromFlags: %v", err)
}
if params.SystemOverlaySize != "16G" || params.WorkDiskSize != "32G" {
t.Fatalf("expected changed disk flags to be included: %+v", params)
}
}
func TestVMCreateParamsFromFlagsRejectsNonPositiveCPUAndMemory(t *testing.T) {
cmd := NewBangerCommand()
vm, _, err := cmd.Find([]string{"vm"})
if err != nil {
t.Fatalf("find vm: %v", err)
}
create, _, err := vm.Find([]string{"create"})
if err != nil {
t.Fatalf("find create: %v", err)
}
if err := create.Flags().Set("vcpu", "0"); err != nil {
t.Fatalf("set vcpu flag: %v", err)
}
if _, err := vmCreateParamsFromFlags(create, "devbox", "default", 0, 0, "", "", false, false); err == nil || !strings.Contains(err.Error(), "vcpu must be a positive integer") {
t.Fatalf("vmCreateParamsFromFlags(vcpu=0) error = %v", err)
}
if err := create.Flags().Set("memory", "-1"); err != nil {
t.Fatalf("set memory flag: %v", err)
}
if _, err := vmCreateParamsFromFlags(create, "devbox", "default", 1, -1, "", "", false, false); err == nil || !strings.Contains(err.Error(), "memory must be a positive integer") {
t.Fatalf("vmCreateParamsFromFlags(memory=-1) error = %v", err)
}
}
func TestRunVMCreatePollsUntilDone(t *testing.T) {
origBegin := vmCreateBeginFunc
origStatus := vmCreateStatusFunc
origCancel := vmCreateCancelFunc
t.Cleanup(func() {
vmCreateBeginFunc = origBegin
vmCreateStatusFunc = origStatus
vmCreateCancelFunc = origCancel
})
vm := model.VMRecord{
ID: "vm-id",
Name: "devbox",
Spec: model.VMSpec{WorkDiskSizeBytes: model.DefaultWorkDiskSize},
Runtime: model.VMRuntime{
State: model.VMStateRunning,
GuestIP: "172.16.0.2",
DNSName: "devbox.vm",
},
}
vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) {
return api.VMCreateBeginResult{
Operation: api.VMCreateOperation{
ID: "op-1",
Stage: "prepare_work_disk",
Detail: "cloning work seed",
},
}, nil
}
statusCalls := 0
vmCreateStatusFunc = func(context.Context, string, string) (api.VMCreateStatusResult, error) {
statusCalls++
if statusCalls == 1 {
return api.VMCreateStatusResult{
Operation: api.VMCreateOperation{
ID: "op-1",
Stage: "wait_opencode",
Detail: "waiting for opencode on guest port 4096",
},
}, nil
}
return api.VMCreateStatusResult{
Operation: api.VMCreateOperation{
ID: "op-1",
Stage: "ready",
Detail: "vm is ready",
Done: true,
Success: true,
VM: &vm,
},
}, nil
}
vmCreateCancelFunc = func(context.Context, string, string) error {
t.Fatal("cancel should not be called")
return nil
}
got, err := runVMCreate(context.Background(), "/tmp/bangerd.sock", &bytes.Buffer{}, api.VMCreateParams{Name: "devbox"})
if err != nil {
t.Fatalf("runVMCreate: %v", err)
}
if got.Name != vm.Name || got.Runtime.GuestIP != vm.Runtime.GuestIP {
t.Fatalf("vm = %+v, want %+v", got, vm)
}
if statusCalls != 2 {
t.Fatalf("statusCalls = %d, want 2", statusCalls)
}
}
func TestVMCreateProgressRendererSuppressesDuplicateLines(t *testing.T) {
var stderr bytes.Buffer
renderer := &vmCreateProgressRenderer{out: &stderr, enabled: true}
renderer.render(api.VMCreateOperation{Stage: "prepare_work_disk", Detail: "cloning work seed"})
renderer.render(api.VMCreateOperation{Stage: "prepare_work_disk", Detail: "cloning work seed"})
renderer.render(api.VMCreateOperation{Stage: "wait_opencode", Detail: "waiting for opencode on guest port 4096"})
lines := strings.Split(strings.TrimSpace(stderr.String()), "\n")
if len(lines) != 2 {
t.Fatalf("rendered lines = %q, want 2 lines", stderr.String())
}
if lines[0] != "[vm create] preparing work disk: cloning work seed" {
t.Fatalf("first line = %q", lines[0])
}
if lines[1] != "[vm create] waiting for opencode: waiting for opencode on guest port 4096" {
t.Fatalf("second line = %q", lines[1])
}
}
func TestVMRunProgressRendererSuppressesDuplicateLines(t *testing.T) {
var stderr bytes.Buffer
renderer := newVMRunProgressRenderer(&stderr)
renderer.render("waiting for guest ssh")
renderer.render("waiting for guest ssh")
renderer.render("overlaying host working tree")
lines := strings.Split(strings.TrimSpace(stderr.String()), "\n")
if len(lines) != 2 {
t.Fatalf("rendered lines = %q, want 2 lines", stderr.String())
}
if lines[0] != "[vm run] waiting for guest ssh" {
t.Fatalf("first line = %q", lines[0])
}
if lines[1] != "[vm run] overlaying host working tree" {
t.Fatalf("second line = %q", lines[1])
}
}
func TestVMSetParamsFromFlagsConflict(t *testing.T) {
if _, err := vmSetParamsFromFlags("devbox", -1, -1, "", true, true); err == nil {
t.Fatal("expected nat conflict error")
}
}
func TestVMSetParamsFromFlagsRejectsNonPositiveCPUAndMemory(t *testing.T) {
if _, err := vmSetParamsFromFlags("devbox", 0, -1, "", false, false); err == nil || !strings.Contains(err.Error(), "vcpu must be a positive integer") {
t.Fatalf("vmSetParamsFromFlags(vcpu=0) error = %v", err)
}
if _, err := vmSetParamsFromFlags("devbox", -1, 0, "", false, false); err == nil || !strings.Contains(err.Error(), "memory must be a positive integer") {
t.Fatalf("vmSetParamsFromFlags(memory=0) error = %v", err)
}
}
func TestAbsolutizeImageRegisterPaths(t *testing.T) {
tmp := t.TempDir()
params := api.ImageRegisterParams{
RootfsPath: filepath.Join(".", "runtime", "rootfs-void.ext4"),
WorkSeedPath: filepath.Join(".", "runtime", "rootfs-void.work-seed.ext4"),
KernelPath: filepath.Join(".", "runtime", "vmlinux"),
InitrdPath: filepath.Join(".", "runtime", "initrd.img"),
ModulesDir: filepath.Join(".", "runtime", "modules"),
}
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd: %v", err)
}
if err := os.Chdir(tmp); err != nil {
t.Fatalf("Chdir(%s): %v", tmp, err)
}
t.Cleanup(func() {
_ = os.Chdir(wd)
})
if err := absolutizeImageRegisterPaths(&params); err != nil {
t.Fatalf("absolutizeImageRegisterPaths: %v", err)
}
for _, value := range []string{
params.RootfsPath,
params.WorkSeedPath,
params.KernelPath,
params.InitrdPath,
params.ModulesDir,
} {
if !filepath.IsAbs(value) {
t.Fatalf("path %q is not absolute", value)
}
}
}
func TestPrintImageListTableShowsRootfsSizes(t *testing.T) {
rootfs := filepath.Join(t.TempDir(), "rootfs.ext4")
if err := os.WriteFile(rootfs, nil, 0o644); err != nil {
t.Fatalf("WriteFile(%s): %v", rootfs, err)
}
if err := os.Truncate(rootfs, 8*1024); err != nil {
t.Fatalf("Truncate(%s): %v", rootfs, err)
}
var out bytes.Buffer
err := printImageListTable(&out, []model.Image{
{
ID: "0123456789abcdef",
Name: "alpine",
Managed: true,
RootfsPath: rootfs,
CreatedAt: time.Now().Add(-1 * time.Hour),
},
{
ID: "fedcba9876543210",
Name: "missing",
Managed: false,
RootfsPath: filepath.Join(t.TempDir(), "missing.ext4"),
CreatedAt: time.Now().Add(-2 * time.Hour),
},
})
if err != nil {
t.Fatalf("printImageListTable() error = %v", err)
}
output := out.String()
if !strings.Contains(output, "ROOTFS SIZE") {
t.Fatalf("output = %q, want rootfs size header", output)
}
if !strings.Contains(output, "alpine") || !strings.Contains(output, "8K") {
t.Fatalf("output = %q, want alpine row with 8K size", output)
}
if strings.Contains(output, rootfs) {
t.Fatalf("output = %q, should not include rootfs path", output)
}
if !strings.Contains(output, "missing") || !strings.Contains(output, "-") {
t.Fatalf("output = %q, want fallback size for missing image", output)
}
}
func TestSelectVMListVMsDefaultsToRunning(t *testing.T) {
now := time.Now()
vms := []model.VMRecord{
{ID: "running-1", State: model.VMStateRunning, CreatedAt: now.Add(-3 * time.Hour)},
{ID: "stopped-1", State: model.VMStateStopped, CreatedAt: now.Add(-2 * time.Hour)},
{ID: "running-2", State: model.VMStateRunning, CreatedAt: now.Add(-1 * time.Hour)},
}
got := selectVMListVMs(vms, false, false)
if len(got) != 2 || got[0].ID != "running-1" || got[1].ID != "running-2" {
t.Fatalf("selectVMListVMs() = %#v, want only running VMs in original order", got)
}
}
func TestSelectVMListVMsLatestUsesFilteredSet(t *testing.T) {
now := time.Now()
vms := []model.VMRecord{
{ID: "running-old", State: model.VMStateRunning, CreatedAt: now.Add(-3 * time.Hour)},
{ID: "stopped-new", State: model.VMStateStopped, CreatedAt: now.Add(-30 * time.Minute)},
{ID: "running-new", State: model.VMStateRunning, CreatedAt: now.Add(-1 * time.Hour)},
}
got := selectVMListVMs(vms, false, true)
if len(got) != 1 || got[0].ID != "running-new" {
t.Fatalf("selectVMListVMs(default latest) = %#v, want latest running VM", got)
}
got = selectVMListVMs(vms, true, true)
if len(got) != 1 || got[0].ID != "stopped-new" {
t.Fatalf("selectVMListVMs(all latest) = %#v, want latest VM across all states", got)
}
}
func TestPrintVMIDListShowsFullIDs(t *testing.T) {
var out bytes.Buffer
err := printVMIDList(&out, []model.VMRecord{{ID: "0123456789abcdef0123456789abcdef"}, {ID: "fedcba9876543210fedcba9876543210"}})
if err != nil {
t.Fatalf("printVMIDList() error = %v", err)
}
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
want := []string{"0123456789abcdef0123456789abcdef", "fedcba9876543210fedcba9876543210"}
if !reflect.DeepEqual(lines, want) {
t.Fatalf("lines = %v, want %v", lines, want)
}
}
func TestPrintVMListTableShowsImageNames(t *testing.T) {
var out bytes.Buffer
err := printVMListTable(&out, []model.VMRecord{
{
ID: "0123456789abcdef",
Name: "alp-fast",
ImageID: "image-alpine-123456",
State: model.VMStateRunning,
CreatedAt: time.Now().Add(-1 * time.Hour),
Spec: model.VMSpec{
VCPUCount: 2,
MemoryMiB: model.DefaultMemoryMiB,
WorkDiskSizeBytes: model.DefaultWorkDiskSize,
},
Runtime: model.VMRuntime{GuestIP: "172.16.0.4"},
},
{
ID: "fedcba9876543210",
Name: "mystery",
ImageID: "abcdef1234567890",
State: model.VMStateStopped,
CreatedAt: time.Now().Add(-2 * time.Hour),
Spec: model.VMSpec{
VCPUCount: 1,
MemoryMiB: 512,
WorkDiskSizeBytes: 4 * 1024 * 1024 * 1024,
},
},
}, map[string]string{
"image-alpine-123456": "alpine",
})
if err != nil {
t.Fatalf("printVMListTable() error = %v", err)
}
output := out.String()
if !strings.Contains(output, "IMAGE") || !strings.Contains(output, "MEM") {
t.Fatalf("output = %q, want vm list headers", output)
}
if !strings.Contains(output, "alp-fast") || !strings.Contains(output, "alpine") {
t.Fatalf("output = %q, want resolved image name", output)
}
if strings.Contains(output, "image-alpine-123456") {
t.Fatalf("output = %q, should not include full image id when name is known", output)
}
if !strings.Contains(output, shortID("abcdef1234567890")) {
t.Fatalf("output = %q, want short image id fallback", output)
}
if !strings.Contains(output, fmt.Sprintf("%d MiB", model.DefaultMemoryMiB)) {
t.Fatalf("output = %q, want updated default memory display", output)
}
}
func TestPrintVMPortsTableSortsAndRendersURLEndpoints(t *testing.T) {
result := api.VMPortsResult{
Name: "alpha",
Ports: []api.VMPort{
{
Proto: "https",
Port: 443,
Endpoint: "https://alpha.vm:443/",
Process: "caddy",
Command: "caddy run",
},
{
Proto: "udp",
Port: 53,
Endpoint: "alpha.vm:53",
Process: "dnsd",
Command: "dnsd --foreground",
},
},
}
var out bytes.Buffer
if err := printVMPortsTable(&out, result); err != nil {
t.Fatalf("printVMPortsTable: %v", err)
}
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
if len(lines) != 3 {
t.Fatalf("lines = %q, want header + 2 rows", lines)
}
if !strings.Contains(lines[0], "PROTO") || !strings.Contains(lines[0], "ENDPOINT") || strings.Contains(lines[0], "VM") || strings.Contains(lines[0], "WEB") {
t.Fatalf("header = %q, want PROTO/ENDPOINT without VM/WEB", lines[0])
}
if !strings.Contains(lines[1], "https") || !strings.Contains(lines[1], "https://alpha.vm:443/") {
t.Fatalf("first row = %q, want https endpoint row", lines[1])
}
if !strings.Contains(lines[2], "udp") || !strings.Contains(lines[2], "alpha.vm:53") {
t.Fatalf("second row = %q, want udp endpoint row", lines[2])
}
}
func TestRunSSHSessionPrintsReminderWhenHealthCheckPasses(t *testing.T) {
origSSHExec := sshExecFunc
origHealth := vmHealthFunc
t.Cleanup(func() {
sshExecFunc = origSSHExec
vmHealthFunc = origHealth
})
sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
return nil
}
vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) {
return api.VMHealthResult{Name: "devbox", Healthy: true}, nil
}
var stderr bytes.Buffer
if err := runSSHSession(context.Background(), "/tmp/bangerd.sock", "devbox", strings.NewReader(""), &bytes.Buffer{}, &stderr, []string{"root@127.0.0.1"}); err != nil {
t.Fatalf("runSSHSession: %v", err)
}
if !strings.Contains(stderr.String(), "devbox is still running") {
t.Fatalf("stderr = %q, want reminder", stderr.String())
}
}
func TestRunSSHSessionPreservesSSHExitStatusOnHealthWarning(t *testing.T) {
origSSHExec := sshExecFunc
origHealth := vmHealthFunc
t.Cleanup(func() {
sshExecFunc = origSSHExec
vmHealthFunc = origHealth
})
sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
return exitErrorWithCode(t, 1)
}
vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) {
return api.VMHealthResult{}, errors.New("dial failed")
}
var stderr bytes.Buffer
err := runSSHSession(context.Background(), "/tmp/bangerd.sock", "devbox", strings.NewReader(""), &bytes.Buffer{}, &stderr, []string{"root@127.0.0.1"})
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("runSSHSession error = %v, want exit error", err)
}
if !strings.Contains(stderr.String(), "failed to check whether devbox is still running") {
t.Fatalf("stderr = %q, want warning", stderr.String())
}
}
func TestRunSSHSessionSkipsReminderOnSSHAuthFailure(t *testing.T) {
origSSHExec := sshExecFunc
origHealth := vmHealthFunc
t.Cleanup(func() {
sshExecFunc = origSSHExec
vmHealthFunc = origHealth
})
healthCalled := false
sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
return exitErrorWithCode(t, 255)
}
vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) {
healthCalled = true
return api.VMHealthResult{Name: "devbox", Healthy: true}, nil
}
var stderr bytes.Buffer
err := runSSHSession(context.Background(), "/tmp/bangerd.sock", "devbox", strings.NewReader(""), &bytes.Buffer{}, &stderr, []string{"root@127.0.0.1"})
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 255 {
t.Fatalf("runSSHSession error = %v, want exit 255", err)
}
if healthCalled {
t.Fatal("vm health should not run after ssh auth failure")
}
if strings.Contains(stderr.String(), "still running") {
t.Fatalf("stderr = %q, should not contain reminder", stderr.String())
}
}
func TestResolveVMTargetsDeduplicatesAndReportsErrors(t *testing.T) {
vms := []model.VMRecord{
testCLIResolvedVM("alpha-id", "alpha"),
testCLIResolvedVM("alpine-id", "alpine"),
testCLIResolvedVM("bravo-id", "bravo"),
}
targets, errs := resolveVMTargets(vms, []string{"alpha", "alpha-id", "al", "missing", "br"})
if len(targets) != 2 {
t.Fatalf("len(targets) = %d, want 2", len(targets))
}
if targets[0].VM.ID != "alpha-id" || targets[0].Ref != "alpha" {
t.Fatalf("targets[0] = %+v, want alpha target", targets[0])
}
if targets[1].VM.ID != "bravo-id" || targets[1].Ref != "br" {
t.Fatalf("targets[1] = %+v, want bravo target", targets[1])
}
if len(errs) != 2 {
t.Fatalf("len(errs) = %d, want 2", len(errs))
}
if errs[0].Ref != "al" || !strings.Contains(errs[0].Err.Error(), "multiple VMs match") {
t.Fatalf("errs[0] = %+v, want ambiguous prefix", errs[0])
}
if errs[1].Ref != "missing" || !strings.Contains(errs[1].Err.Error(), `vm "missing" not found`) {
t.Fatalf("errs[1] = %+v, want missing vm", errs[1])
}
}
func TestResolveVMRefPrefersExactMatchBeforePrefix(t *testing.T) {
vms := []model.VMRecord{
testCLIResolvedVM("1111111111111111111111111111111111111111111111111111111111111111", "alpha"),
testCLIResolvedVM("alpha222222222222222222222222222222222222222222222222222222222222", "bravo"),
}
vm, err := resolveVMRef(vms, "alpha")
if err != nil {
t.Fatalf("resolveVMRef(alpha): %v", err)
}
if vm.Name != "alpha" {
t.Fatalf("resolveVMRef(alpha) = %+v, want exact-name vm", vm)
}
}
func TestExecuteVMActionBatchRunsConcurrentlyAndPreservesOrder(t *testing.T) {
targets := []resolvedVMTarget{
{Ref: "alpha", VM: testCLIResolvedVM("alpha-id", "alpha")},
{Ref: "bravo", VM: testCLIResolvedVM("bravo-id", "bravo")},
}
started := make(chan string, len(targets))
release := make(chan struct{})
done := make(chan []vmBatchActionResult, 1)
go func() {
done <- executeVMActionBatch(context.Background(), targets, func(ctx context.Context, id string) (model.VMRecord, error) {
started <- id
<-release
return model.VMRecord{ID: id, Name: id}, nil
})
}()
for range targets {
select {
case <-started:
case <-time.After(500 * time.Millisecond):
t.Fatal("batch actions did not overlap")
}
}
close(release)
var results []vmBatchActionResult
select {
case results = <-done:
case <-time.After(500 * time.Millisecond):
t.Fatal("executeVMActionBatch did not finish")
}
if len(results) != len(targets) {
t.Fatalf("len(results) = %d, want %d", len(results), len(targets))
}
for index, result := range results {
if result.Target.Ref != targets[index].Ref {
t.Fatalf("results[%d].Target.Ref = %q, want %q", index, result.Target.Ref, targets[index].Ref)
}
if result.VM.ID != targets[index].VM.ID {
t.Fatalf("results[%d].VM.ID = %q, want %q", index, result.VM.ID, targets[index].VM.ID)
}
}
}
func TestSSHCommandArgs(t *testing.T) {
args, err := sshCommandArgs(model.DaemonConfig{SSHKeyPath: "/bundle/id_ed25519"}, "172.16.0.2", []string{"--", "uname", "-a"})
if err != nil {
t.Fatalf("sshCommandArgs: %v", err)
}
want := []string{
"-F", "/dev/null",
"-i", "/bundle/id_ed25519",
"-o", "IdentitiesOnly=yes",
"-o", "BatchMode=yes",
"-o", "PreferredAuthentications=publickey",
"-o", "PasswordAuthentication=no",
"-o", "KbdInteractiveAuthentication=no",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"root@172.16.0.2",
"--", "uname", "-a",
}
if !reflect.DeepEqual(args, want) {
t.Fatalf("args = %v, want %v", args, want)
}
}
func TestRunVMACPBridgesOverSSH(t *testing.T) {
origVMSSH := vmSSHFunc
origSSHExec := sshExecFunc
t.Cleanup(func() {
vmSSHFunc = origVMSSH
sshExecFunc = origSSHExec
})
vmSSHFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMSSHResult, error) {
if socketPath != "/tmp/bangerd.sock" {
t.Fatalf("socketPath = %q, want /tmp/bangerd.sock", socketPath)
}
if idOrName != "devbox" {
t.Fatalf("idOrName = %q, want devbox", idOrName)
}
return api.VMSSHResult{Name: "devbox", GuestIP: "172.16.0.2"}, nil
}
var gotArgs []string
var gotStdin string
sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
gotArgs = append([]string(nil), args...)
data, err := io.ReadAll(stdin)
if err != nil {
t.Fatalf("ReadAll(stdin): %v", err)
}
gotStdin = string(data)
return nil
}
if err := runVMACP(
context.Background(),
"/tmp/bangerd.sock",
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
strings.NewReader("client stream"),
&bytes.Buffer{},
&bytes.Buffer{},
"devbox",
"",
); err != nil {
t.Fatalf("runVMACP: %v", err)
}
if gotStdin != "client stream" {
t.Fatalf("stdin = %q, want client stream", gotStdin)
}
joined := strings.Join(gotArgs, " ")
for _, want := range []string{
"-T",
"-F /dev/null",
"-i /tmp/id_ed25519",
"-o LogLevel=ERROR",
"root@172.16.0.2",
"bash -lc",
} {
if !strings.Contains(joined, want) {
t.Fatalf("ssh args = %q, want %q", joined, want)
}
}
remoteCommand := gotArgs[len(gotArgs)-1]
if !strings.Contains(remoteCommand, `exec opencode acp --cwd "$DIR"`) {
t.Fatalf("remote command = %q, want ACP exec", remoteCommand)
}
if !strings.Contains(remoteCommand, "REPO_DIR='/root/repo'") {
t.Fatalf("remote command = %q, want repo fallback", remoteCommand)
}
}
func TestVMACPRemoteCommandDefaultsToRepoThenRoot(t *testing.T) {
got := vmACPRemoteCommand("")
for _, want := range []string{
"REPO_DIR='/root/repo'",
"DEFAULT_DIR='/root'",
`if [ -d "$REPO_DIR" ]; then DIR="$REPO_DIR"; else DIR="$DEFAULT_DIR"; fi`,
`exec opencode acp --cwd "$DIR"`,
} {
if !strings.Contains(got, want) {
t.Fatalf("vmACPRemoteCommand() = %q, want %q", got, want)
}
}
}
func TestVMACPRemoteCommandUsesExplicitCWD(t *testing.T) {
got := vmACPRemoteCommand("/workspace/project")
if !strings.Contains(got, "DIR='/workspace/project'") {
t.Fatalf("vmACPRemoteCommand() = %q, want explicit cwd", got)
}
if strings.Contains(got, "REPO_DIR=") {
t.Fatalf("vmACPRemoteCommand() = %q, want no repo fallback", got)
}
}
func TestValidateSSHPrereqs(t *testing.T) {
dir := t.TempDir()
keyPath := filepath.Join(dir, "id_ed25519")
if err := os.WriteFile(keyPath, []byte("key"), 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
if err := validateSSHPrereqs(model.DaemonConfig{SSHKeyPath: keyPath}); err != nil {
t.Fatalf("validateSSHPrereqs: %v", err)
}
}
func exitErrorWithCode(t *testing.T, code int) *exec.ExitError {
t.Helper()
cmd := exec.Command("bash", "-lc", fmt.Sprintf("exit %d", code))
err := cmd.Run()
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("exitErrorWithCode(%d) error = %v, want exit error", code, err)
}
return exitErr
}
func TestValidateSSHPrereqsFailsForMissingKey(t *testing.T) {
err := validateSSHPrereqs(model.DaemonConfig{SSHKeyPath: "/does/not/exist"})
if err == nil || !strings.Contains(err.Error(), "ssh private key") {
t.Fatalf("validateSSHPrereqs() error = %v, want missing key", err)
}
}
func TestResolveVMRunSourcePathDefaultsToCWD(t *testing.T) {
origCWD := cwdFunc
t.Cleanup(func() {
cwdFunc = origCWD
})
want := t.TempDir()
cwdFunc = func() (string, error) {
return want, nil
}
got, err := resolveVMRunSourcePath("")
if err != nil {
t.Fatalf("resolveVMRunSourcePath: %v", err)
}
if got != want {
t.Fatalf("resolveVMRunSourcePath() = %q, want %q", got, want)
}
}
func TestInspectVMRunRepoUsesRepoRootAndOverlayPaths(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
repoRoot := t.TempDir()
globalConfigPath := filepath.Join(t.TempDir(), "global.gitconfig")
t.Setenv("GIT_CONFIG_GLOBAL", globalConfigPath)
testRunGit(t, repoRoot, "config", "--global", "user.email", "global@example.com")
testRunGit(t, repoRoot, "config", "--global", "user.name", "Global User")
testRunGit(t, repoRoot, "init")
testRunGit(t, repoRoot, "remote", "add", "origin", "https://example.com/repo.git")
testRunGit(t, repoRoot, "config", "user.email", "test@example.com")
testRunGit(t, repoRoot, "config", "user.name", "Banger Test")
if err := os.MkdirAll(filepath.Join(repoRoot, "dir"), 0o755); err != nil {
t.Fatalf("MkdirAll(dir): %v", err)
}
if err := os.WriteFile(filepath.Join(repoRoot, ".gitignore"), []byte("ignored.txt\n"), 0o644); err != nil {
t.Fatalf("WriteFile(.gitignore): %v", err)
}
if err := os.WriteFile(filepath.Join(repoRoot, "tracked.txt"), []byte("tracked\n"), 0o644); err != nil {
t.Fatalf("WriteFile(tracked.txt): %v", err)
}
if err := os.WriteFile(filepath.Join(repoRoot, "dir", "keep.txt"), []byte("keep\n"), 0o644); err != nil {
t.Fatalf("WriteFile(keep.txt): %v", err)
}
testRunGit(t, repoRoot, "add", ".")
testRunGit(t, repoRoot, "commit", "-m", "init")
testRunGit(t, repoRoot, "checkout", "-b", "trunk")
if err := os.WriteFile(filepath.Join(repoRoot, "tracked.txt"), []byte("tracked local\n"), 0o644); err != nil {
t.Fatalf("WriteFile(tracked.txt local): %v", err)
}
if err := os.WriteFile(filepath.Join(repoRoot, "untracked.txt"), []byte("untracked\n"), 0o644); err != nil {
t.Fatalf("WriteFile(untracked.txt): %v", err)
}
if err := os.WriteFile(filepath.Join(repoRoot, "ignored.txt"), []byte("ignored\n"), 0o644); err != nil {
t.Fatalf("WriteFile(ignored.txt): %v", err)
}
spec, err := inspectVMRunRepo(context.Background(), filepath.Join(repoRoot, "dir"), "", "HEAD")
if err != nil {
t.Fatalf("inspectVMRunRepo: %v", err)
}
if spec.RepoRoot != repoRoot {
t.Fatalf("RepoRoot = %q, want %q", spec.RepoRoot, repoRoot)
}
if spec.RepoName != filepath.Base(repoRoot) {
t.Fatalf("RepoName = %q, want %q", spec.RepoName, filepath.Base(repoRoot))
}
if spec.CurrentBranch != "trunk" {
t.Fatalf("CurrentBranch = %q, want trunk", spec.CurrentBranch)
}
if spec.HeadCommit == "" {
t.Fatal("HeadCommit should not be empty")
}
if spec.BaseCommit != spec.HeadCommit {
t.Fatalf("BaseCommit = %q, want head %q", spec.BaseCommit, spec.HeadCommit)
}
if spec.OriginURL != "https://example.com/repo.git" {
t.Fatalf("OriginURL = %q, want https://example.com/repo.git", spec.OriginURL)
}
if spec.GitUserName != "Banger Test" {
t.Fatalf("GitUserName = %q, want Banger Test", spec.GitUserName)
}
if spec.GitUserEmail != "test@example.com" {
t.Fatalf("GitUserEmail = %q, want test@example.com", spec.GitUserEmail)
}
wantOverlay := []string{".gitignore", "dir/keep.txt", "tracked.txt", "untracked.txt"}
if !reflect.DeepEqual(spec.OverlayPaths, wantOverlay) {
t.Fatalf("OverlayPaths = %v, want %v", spec.OverlayPaths, wantOverlay)
}
}
func TestInspectVMRunRepoRejectsSubmodules(t *testing.T) {
repoRoot := t.TempDir()
origHostCommandOutput := hostCommandOutputFunc
t.Cleanup(func() {
hostCommandOutputFunc = origHostCommandOutput
})
hostCommandOutputFunc = func(ctx context.Context, name string, args ...string) ([]byte, error) {
t.Helper()
if name != "git" {
t.Fatalf("command = %q, want git", name)
}
switch {
case reflect.DeepEqual(args, []string{"-C", repoRoot, "rev-parse", "--show-toplevel"}):
return []byte(repoRoot + "\n"), nil
case reflect.DeepEqual(args, []string{"-C", repoRoot, "rev-parse", "--is-bare-repository"}):
return []byte("false\n"), nil
case reflect.DeepEqual(args, []string{"-C", repoRoot, "ls-files", "--stage", "-z"}):
return []byte("160000 deadbeef 0\tvendor/submodule\x00"), nil
default:
t.Fatalf("unexpected git args: %v", args)
return nil, nil
}
}
_, err := inspectVMRunRepo(context.Background(), repoRoot, "", "HEAD")
if err == nil || !strings.Contains(err.Error(), "submodules") {
t.Fatalf("inspectVMRunRepo() error = %v, want submodule rejection", err)
}
}
func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
repoRoot := t.TempDir()
repoCopyDir := filepath.Join(t.TempDir(), "repo-copy")
origBegin := vmCreateBeginFunc
origStatus := vmCreateStatusFunc
origCancel := vmCreateCancelFunc
origWaitForSSH := guestWaitForSSHFunc
origGuestDial := guestDialFunc
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
origBuildVMRunToolingPlan := buildVMRunToolingPlanFunc
origOpencodeExec := opencodeExecFunc
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
t.Cleanup(func() {
vmCreateBeginFunc = origBegin
vmCreateStatusFunc = origStatus
vmCreateCancelFunc = origCancel
guestWaitForSSHFunc = origWaitForSSH
guestDialFunc = origGuestDial
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
buildVMRunToolingPlanFunc = origBuildVMRunToolingPlan
opencodeExecFunc = origOpencodeExec
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
})
vm := model.VMRecord{
ID: "vm-id",
Name: "devbox",
Runtime: model.VMRuntime{
State: model.VMStateRunning,
GuestIP: "172.16.0.2",
DNSName: "devbox.vm",
},
}
vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) {
return api.VMCreateBeginResult{
Operation: api.VMCreateOperation{
ID: "op-1",
Stage: "ready",
Detail: "vm is ready",
Done: true,
Success: true,
VM: &vm,
},
}, nil
}
vmCreateStatusFunc = func(context.Context, string, string) (api.VMCreateStatusResult, error) {
t.Fatal("vmCreateStatusFunc should not be called")
return api.VMCreateStatusResult{}, nil
}
vmCreateCancelFunc = func(context.Context, string, string) error {
t.Fatal("vmCreateCancelFunc should not be called")
return nil
}
fakeClient := &testVMRunGuestClient{}
waitAddress := ""
waitKeyPath := ""
waitInterval := time.Duration(0)
guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
waitAddress = address
waitKeyPath = privateKeyPath
waitInterval = interval
return nil
}
dialAddress := ""
dialKeyPath := ""
guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) {
dialAddress = address
dialKeyPath = privateKeyPath
return fakeClient, nil
}
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
if spec.RepoRoot != repoRoot {
t.Fatalf("spec.RepoRoot = %q, want %q", spec.RepoRoot, repoRoot)
}
return repoCopyDir, func() {}, nil
}
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
return true, nil
}
buildVMRunToolingPlanFunc = func(context.Context, string) toolingplan.Plan {
return toolingplan.Plan{
Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}},
Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}},
}
}
var attachArgs []string
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
attachArgs = append([]string(nil), args...)
return nil
}
spec := vmRunRepoSpec{
RepoRoot: repoRoot,
RepoName: "repo",
HeadCommit: "deadbeef",
CurrentBranch: "main",
BranchName: "feature",
BaseCommit: "cafebabe",
GitUserName: "Repo User",
GitUserEmail: "repo@example.com",
OverlayPaths: []string{"tracked.txt", "nested/keep.txt"},
}
err := runVMRun(
context.Background(),
"/tmp/bangerd.sock",
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
strings.NewReader(""),
&bytes.Buffer{},
&bytes.Buffer{},
api.VMCreateParams{Name: "devbox"},
spec,
)
if err != nil {
t.Fatalf("runVMRun: %v", err)
}
if waitAddress != "172.16.0.2:22" {
t.Fatalf("waitAddress = %q, want 172.16.0.2:22", waitAddress)
}
if waitKeyPath != "/tmp/id_ed25519" {
t.Fatalf("waitKeyPath = %q, want /tmp/id_ed25519", waitKeyPath)
}
if waitInterval <= 0 {
t.Fatalf("waitInterval = %s, want positive interval", waitInterval)
}
if dialAddress != waitAddress {
t.Fatalf("dialAddress = %q, want %q", dialAddress, waitAddress)
}
if dialKeyPath != waitKeyPath {
t.Fatalf("dialKeyPath = %q, want %q", dialKeyPath, waitKeyPath)
}
if fakeClient.tarSourceDir != repoCopyDir {
t.Fatalf("tarSourceDir = %q, want %q", fakeClient.tarSourceDir, repoCopyDir)
}
if fakeClient.tarCommand != "rm -rf '/root/repo' && mkdir -p '/root/repo' && tar -o -C '/root/repo' --strip-components=1 -xf -" {
t.Fatalf("tarCommand = %q", fakeClient.tarCommand)
}
if len(fakeClient.uploads) != 2 {
t.Fatalf("uploads = %d, want 2", len(fakeClient.uploads))
}
if fakeClient.uploads[0].path != vmRunToolingHarnessPromptPath("repo") {
t.Fatalf("prompt upload path = %q, want %q", fakeClient.uploads[0].path, vmRunToolingHarnessPromptPath("repo"))
}
if fakeClient.uploads[0].mode != 0o644 {
t.Fatalf("prompt upload mode = %v, want 0644", fakeClient.uploads[0].mode)
}
if !strings.Contains(string(fakeClient.uploads[0].data), `Do not edit repository files.`) {
t.Fatalf("prompt upload data = %q, want prompt body", string(fakeClient.uploads[0].data))
}
if !strings.Contains(string(fakeClient.uploads[0].data), `Planned deterministic install: go@1.25.0 from go.mod`) {
t.Fatalf("prompt upload data = %q, want deterministic install summary", string(fakeClient.uploads[0].data))
}
if !strings.Contains(string(fakeClient.uploads[0].data), `Deterministic skip: python (no .python-version)`) {
t.Fatalf("prompt upload data = %q, want deterministic skip summary", string(fakeClient.uploads[0].data))
}
if fakeClient.uploadPath != vmRunToolingHarnessPath("repo") {
t.Fatalf("uploadPath = %q, want %q", fakeClient.uploadPath, vmRunToolingHarnessPath("repo"))
}
if fakeClient.uploadMode != 0o755 {
t.Fatalf("uploadMode = %v, want 0755", fakeClient.uploadMode)
}
if !strings.Contains(string(fakeClient.uploadData), `run_best_effort "$MISE_BIN" install`) {
t.Fatalf("uploadData = %q, want mise install best-effort step", string(fakeClient.uploadData))
}
if !strings.Contains(string(fakeClient.uploadData), fmt.Sprintf(`INSTALL_TIMEOUT_SECS=%d`, vmRunToolingInstallTimeoutSeconds)) {
t.Fatalf("uploadData = %q, want deterministic install timeout", string(fakeClient.uploadData))
}
if !strings.Contains(string(fakeClient.uploadData), `deterministic install: go@1.25.0 (go.mod)`) {
t.Fatalf("uploadData = %q, want deterministic install log", string(fakeClient.uploadData))
}
if !strings.Contains(string(fakeClient.uploadData), `run_bounded_best_effort "$INSTALL_TIMEOUT_SECS" "$MISE_BIN" use -g --pin 'go@1.25.0'`) {
t.Fatalf("uploadData = %q, want deterministic go install step", string(fakeClient.uploadData))
}
if !strings.Contains(string(fakeClient.uploadData), `deterministic skip: python (no .python-version)`) {
t.Fatalf("uploadData = %q, want deterministic skip log", string(fakeClient.uploadData))
}
if !strings.Contains(string(fakeClient.uploadData), `run_best_effort "$MISE_BIN" reshim`) {
t.Fatalf("uploadData = %q, want deterministic reshim step", string(fakeClient.uploadData))
}
if !strings.Contains(fakeClient.launchScript, `nohup bash "$HELPER" >"$LOG" 2>&1 </dev/null &`) {
t.Fatalf("launchScript = %q, want nohup launcher", fakeClient.launchScript)
}
if !strings.Contains(fakeClient.launchScript, vmRunToolingHarnessLogPath("repo")) {
t.Fatalf("launchScript = %q, want tooling harness log path", fakeClient.launchScript)
}
if !strings.Contains(fakeClient.script, `git -C "$DIR" checkout -B 'feature' 'cafebabe'`) {
t.Fatalf("script = %q, want guest branch checkout", fakeClient.script)
}
if !strings.Contains(fakeClient.script, `find "$DIR" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +`) {
t.Fatalf("script = %q, want guest worktree reset", fakeClient.script)
}
if !strings.Contains(fakeClient.script, `git config --global --add safe.directory "$DIR"`) {
t.Fatalf("script = %q, want guest safe.directory config", fakeClient.script)
}
if !strings.Contains(fakeClient.script, `git -C "$DIR" config user.name 'Repo User'`) {
t.Fatalf("script = %q, want guest repo user.name config", fakeClient.script)
}
if !strings.Contains(fakeClient.script, `git -C "$DIR" config user.email 'repo@example.com'`) {
t.Fatalf("script = %q, want guest repo user.email config", fakeClient.script)
}
if fakeClient.streamSourceDir != repoRoot {
t.Fatalf("streamSourceDir = %q, want %q", fakeClient.streamSourceDir, repoRoot)
}
if !reflect.DeepEqual(fakeClient.streamEntries, spec.OverlayPaths) {
t.Fatalf("streamEntries = %v, want %v", fakeClient.streamEntries, spec.OverlayPaths)
}
if fakeClient.streamCommand != "tar -o -C '/root/repo' --strip-components=1 -xf -" {
t.Fatalf("streamCommand = %q", fakeClient.streamCommand)
}
wantAttach := []string{"attach", "--dir", "/root/repo", "http://172.16.0.2:4096"}
if !reflect.DeepEqual(attachArgs, wantAttach) {
t.Fatalf("attachArgs = %v, want %v", attachArgs, wantAttach)
}
if !fakeClient.closed {
t.Fatal("guest client should be closed")
}
}
func TestVMRunPrintsPostCreateProgress(t *testing.T) {
origBegin := vmCreateBeginFunc
origStatus := vmCreateStatusFunc
origCancel := vmCreateCancelFunc
origWaitForSSH := guestWaitForSSHFunc
origGuestDial := guestDialFunc
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
origOpencodeExec := opencodeExecFunc
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
t.Cleanup(func() {
vmCreateBeginFunc = origBegin
vmCreateStatusFunc = origStatus
vmCreateCancelFunc = origCancel
guestWaitForSSHFunc = origWaitForSSH
guestDialFunc = origGuestDial
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
opencodeExecFunc = origOpencodeExec
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
})
vm := model.VMRecord{
ID: "vm-id",
Name: "devbox",
Runtime: model.VMRuntime{
State: model.VMStateRunning,
GuestIP: "172.16.0.2",
},
}
vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) {
return api.VMCreateBeginResult{
Operation: api.VMCreateOperation{
ID: "op-1",
Stage: "ready",
Detail: "vm is ready",
Done: true,
Success: true,
VM: &vm,
},
}, nil
}
vmCreateStatusFunc = func(context.Context, string, string) (api.VMCreateStatusResult, error) {
t.Fatal("vmCreateStatusFunc should not be called")
return api.VMCreateStatusResult{}, nil
}
vmCreateCancelFunc = func(context.Context, string, string) error {
t.Fatal("vmCreateCancelFunc should not be called")
return nil
}
guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
return nil
}
guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) {
return &testVMRunGuestClient{}, nil
}
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
return t.TempDir(), func() {}, nil
}
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
return true, nil
}
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
return nil
}
var stderr bytes.Buffer
err := runVMRun(
context.Background(),
"/tmp/bangerd.sock",
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
strings.NewReader(""),
&bytes.Buffer{},
&stderr,
api.VMCreateParams{Name: "devbox"},
vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"},
)
if err != nil {
t.Fatalf("runVMRun: %v", err)
}
output := stderr.String()
for _, want := range []string{
"[vm run] waiting for guest ssh",
"[vm run] preparing shallow repo",
"[vm run] copying repo metadata to guest",
"[vm run] preparing guest checkout",
"[vm run] overlaying host working tree",
"[vm run] starting tooling harness",
"[vm run] tooling harness log: /root/.cache/banger/vm-run-tooling-repo.log",
"[vm run] attaching opencode",
} {
if !strings.Contains(output, want) {
t.Fatalf("stderr = %q, want %q", output, want)
}
}
}
func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
origBegin := vmCreateBeginFunc
origStatus := vmCreateStatusFunc
origCancel := vmCreateCancelFunc
origWaitForSSH := guestWaitForSSHFunc
origGuestDial := guestDialFunc
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
origOpencodeExec := opencodeExecFunc
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
t.Cleanup(func() {
vmCreateBeginFunc = origBegin
vmCreateStatusFunc = origStatus
vmCreateCancelFunc = origCancel
guestWaitForSSHFunc = origWaitForSSH
guestDialFunc = origGuestDial
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
opencodeExecFunc = origOpencodeExec
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
})
vm := model.VMRecord{
ID: "vm-id",
Name: "devbox",
Runtime: model.VMRuntime{
State: model.VMStateRunning,
GuestIP: "172.16.0.2",
},
}
vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) {
return api.VMCreateBeginResult{Operation: api.VMCreateOperation{ID: "op-1", Stage: "ready", Detail: "vm is ready", Done: true, Success: true, VM: &vm}}, nil
}
vmCreateStatusFunc = func(context.Context, string, string) (api.VMCreateStatusResult, error) {
t.Fatal("vmCreateStatusFunc should not be called")
return api.VMCreateStatusResult{}, nil
}
vmCreateCancelFunc = func(context.Context, string, string) error {
t.Fatal("vmCreateCancelFunc should not be called")
return nil
}
guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
return nil
}
fakeClient := &testVMRunGuestClient{launchErr: errors.New("launch failed")}
guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) {
return fakeClient, nil
}
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
return t.TempDir(), func() {}, nil
}
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
return true, nil
}
attachCalled := false
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
attachCalled = true
return nil
}
var stderr bytes.Buffer
err := runVMRun(
context.Background(),
"/tmp/bangerd.sock",
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
strings.NewReader(""),
&bytes.Buffer{},
&stderr,
api.VMCreateParams{Name: "devbox"},
vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"},
)
if err != nil {
t.Fatalf("runVMRun: %v", err)
}
if !attachCalled {
t.Fatal("opencode attach should still run when tooling harness launch fails")
}
if !strings.Contains(stderr.String(), "[vm run] warning: tooling harness start failed: launch tooling harness: launch failed") {
t.Fatalf("stderr = %q, want tooling harness warning", stderr.String())
}
}
func TestRunVMRunFallsBackToGuestOpencodeWhenHostAttachUnsupported(t *testing.T) {
repoRoot := t.TempDir()
origBegin := vmCreateBeginFunc
origStatus := vmCreateStatusFunc
origCancel := vmCreateCancelFunc
origWaitForSSH := guestWaitForSSHFunc
origGuestDial := guestDialFunc
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
origOpencodeExec := opencodeExecFunc
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
origSSHExec := sshExecFunc
t.Cleanup(func() {
vmCreateBeginFunc = origBegin
vmCreateStatusFunc = origStatus
vmCreateCancelFunc = origCancel
guestWaitForSSHFunc = origWaitForSSH
guestDialFunc = origGuestDial
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
opencodeExecFunc = origOpencodeExec
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
sshExecFunc = origSSHExec
})
vm := model.VMRecord{
ID: "vm-id",
Name: "devbox",
Runtime: model.VMRuntime{
State: model.VMStateRunning,
GuestIP: "172.16.0.2",
},
}
vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) {
return api.VMCreateBeginResult{Operation: api.VMCreateOperation{ID: "op-1", Stage: "ready", Detail: "vm is ready", Done: true, Success: true, VM: &vm}}, nil
}
vmCreateStatusFunc = func(context.Context, string, string) (api.VMCreateStatusResult, error) {
t.Fatal("vmCreateStatusFunc should not be called")
return api.VMCreateStatusResult{}, nil
}
vmCreateCancelFunc = func(context.Context, string, string) error {
t.Fatal("vmCreateCancelFunc should not be called")
return nil
}
guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
return nil
}
guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) {
return &testVMRunGuestClient{}, nil
}
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
return t.TempDir(), func() {}, nil
}
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
return false, nil
}
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
t.Fatalf("opencodeExecFunc should not be called when host attach is unsupported: %v", args)
return nil
}
var sshArgs []string
sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
sshArgs = append([]string(nil), args...)
return nil
}
var stderr bytes.Buffer
err := runVMRun(
context.Background(),
"/tmp/bangerd.sock",
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
strings.NewReader(""),
&bytes.Buffer{},
&stderr,
api.VMCreateParams{Name: "devbox"},
vmRunRepoSpec{RepoRoot: repoRoot, RepoName: "repo", HeadCommit: "deadbeef"},
)
if err != nil {
t.Fatalf("runVMRun: %v", err)
}
if len(sshArgs) < 3 {
t.Fatalf("sshArgs = %v, want fallback SSH invocation", sshArgs)
}
if sshArgs[len(sshArgs)-3] != "bash" || sshArgs[len(sshArgs)-2] != "-lc" {
t.Fatalf("sshArgs = %v, want bash -lc fallback command", sshArgs)
}
if sshArgs[len(sshArgs)-1] != "cd '/root/repo' && exec opencode ." {
t.Fatalf("ssh fallback command = %q, want guest opencode launch", sshArgs[len(sshArgs)-1])
}
if !strings.Contains(stderr.String(), "[vm run] host opencode has no attach support; starting guest opencode over ssh") {
t.Fatalf("stderr = %q, want SSH fallback progress", stderr.String())
}
}
func TestOpencodeAttachHelpOutputSupported(t *testing.T) {
if !opencodeAttachHelpOutputSupported([]byte("opencode attach [url]\n\nAttach a terminal")) {
t.Fatal("expected attach help output to be recognized")
}
if opencodeAttachHelpOutputSupported([]byte("opencode [project]\n\nCommands:\n opencode run [message..]")) {
t.Fatal("unexpected attach support for top-level help output")
}
}
func TestVMRunToolingHarnessScriptUsesMiseOnly(t *testing.T) {
script := vmRunToolingHarnessScript(vmRunRepoSpec{RepoName: "repo"}, toolingplan.Plan{
Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}},
Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}},
})
for _, want := range []string{
`if [ -f .mise.toml ] || [ -f .tool-versions ]; then`,
"PROMPT_FILE=" + shellQuote(vmRunToolingHarnessPromptPath("repo")),
fmt.Sprintf("INSTALL_TIMEOUT_SECS=%d", vmRunToolingInstallTimeoutSeconds),
"MODEL=" + shellQuote(vmRunToolingHarnessModel),
fmt.Sprintf("TIMEOUT_SECS=%d", vmRunToolingHarnessTimeoutSeconds),
`run_best_effort "$MISE_BIN" install`,
`deterministic install: go@1.25.0 (go.mod)`,
`run_bounded_best_effort "$INSTALL_TIMEOUT_SECS" "$MISE_BIN" use -g --pin 'go@1.25.0'`,
`deterministic skip: python (no .python-version)`,
`run_best_effort "$MISE_BIN" reshim`,
`run_bounded_best_effort "$TIMEOUT_SECS" bash -lc 'exec "$1" run --format json -m "$2" "$(cat "$3")"' _ "$OPENCODE_BIN" "$MODEL" "$PROMPT_FILE"`,
`command timed out after ${timeout_secs}s: $*`,
`tooling prompt file missing: $PROMPT_FILE`,
} {
if !strings.Contains(script, want) {
t.Fatalf("script = %q, want %q", script, want)
}
}
for _, unwanted := range []string{"git add", "cat > .mise.toml", "cat > .tool-versions"} {
if strings.Contains(script, unwanted) {
t.Fatalf("script = %q, want no %q", script, unwanted)
}
}
}
func TestPrepareVMRunRepoCopyCreatesShallowMetadataCopy(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
repoRoot := t.TempDir()
testRunGit(t, repoRoot, "init")
testRunGit(t, repoRoot, "remote", "add", "origin", "https://example.com/repo.git")
for i := 0; i < 12; i++ {
name := fmt.Sprintf("file-%02d.txt", i)
if err := os.WriteFile(filepath.Join(repoRoot, name), []byte(fmt.Sprintf("commit-%02d\n", i)), 0o644); err != nil {
t.Fatalf("WriteFile(%s): %v", name, err)
}
testRunGit(t, repoRoot, "add", name)
testRunGit(t, repoRoot, "commit", "-m", fmt.Sprintf("commit-%02d", i))
}
baseCommit := strings.TrimSpace(testRunGit(t, repoRoot, "rev-parse", "HEAD~5"))
repoCopyDir, cleanup, err := prepareVMRunRepoCopy(context.Background(), vmRunRepoSpec{
RepoRoot: repoRoot,
RepoName: "repo",
BranchName: "feature",
BaseCommit: baseCommit,
HeadCommit: strings.TrimSpace(testRunGit(t, repoRoot, "rev-parse", "HEAD")),
OriginURL: "https://example.com/repo.git",
OverlayPaths: []string{"file-11.txt"},
})
if err != nil {
t.Fatalf("prepareVMRunRepoCopy: %v", err)
}
defer cleanup()
entries, err := os.ReadDir(repoCopyDir)
if err != nil {
t.Fatalf("ReadDir(repoCopyDir): %v", err)
}
if len(entries) != 1 || entries[0].Name() != ".git" {
t.Fatalf("repo copy entries = %v, want only .git", entries)
}
if got := strings.TrimSpace(testRunGit(t, repoCopyDir, "rev-parse", "--is-shallow-repository")); got != "true" {
t.Fatalf("is-shallow-repository = %q, want true", got)
}
if got := strings.TrimSpace(testRunGit(t, repoCopyDir, "config", "--get", "remote.origin.url")); got != "https://example.com/repo.git" {
t.Fatalf("remote.origin.url = %q, want https://example.com/repo.git", got)
}
if _, err := exec.Command("git", "-C", repoCopyDir, "cat-file", "-e", baseCommit+"^{commit}").CombinedOutput(); err != nil {
t.Fatalf("cat-file -e %s^{commit}: %v", baseCommit, err)
}
}
func TestVMRunCheckoutScriptSkipsRepoGitIdentityWhenIncomplete(t *testing.T) {
script := vmRunCheckoutScript(vmRunRepoSpec{
RepoName: "repo",
HeadCommit: "deadbeef",
CurrentBranch: "main",
GitUserName: "Repo User",
})
if strings.Contains(script, `git -C "$DIR" config user.name`) || strings.Contains(script, `git -C "$DIR" config user.email`) {
t.Fatalf("script = %q, want no repo-local git identity commands", script)
}
}
func TestVMRunGuestDirIsFixed(t *testing.T) {
if got := vmRunGuestDir(); got != "/root/repo" {
t.Fatalf("vmRunGuestDir() = %q, want /root/repo", got)
}
}
func TestNewBangerdCommandRejectsArgs(t *testing.T) {
cmd := NewBangerdCommand()
cmd.SetArgs([]string{"extra"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected extra args to be rejected")
}
}
func TestDaemonOutdated(t *testing.T) {
dir := t.TempDir()
current := filepath.Join(dir, "bangerd-current")
same := filepath.Join(dir, "bangerd-same")
stale := filepath.Join(dir, "bangerd-stale")
if err := os.WriteFile(current, []byte("current"), 0o755); err != nil {
t.Fatalf("write current: %v", err)
}
if err := os.Link(current, same); err != nil {
t.Fatalf("hard link: %v", err)
}
if err := os.WriteFile(stale, []byte("stale"), 0o755); err != nil {
t.Fatalf("write stale: %v", err)
}
origBangerdPath := bangerdPathFunc
origDaemonExePath := daemonExePath
t.Cleanup(func() {
bangerdPathFunc = origBangerdPath
daemonExePath = origDaemonExePath
})
bangerdPathFunc = func() (string, error) {
return current, nil
}
daemonExePath = func(pid int) string {
if pid == 1 {
return same
}
return stale
}
if daemonOutdated(1) {
t.Fatal("expected matching daemon executable to be current")
}
if !daemonOutdated(2) {
t.Fatal("expected replaced daemon executable to be outdated")
}
}
func TestDaemonStatusIncludesLogPathWhenStopped(t *testing.T) {
configHome := filepath.Join(t.TempDir(), "config")
stateHome := filepath.Join(t.TempDir(), "state")
runtimeHome := filepath.Join(t.TempDir(), "runtime")
t.Setenv("XDG_CONFIG_HOME", configHome)
t.Setenv("XDG_STATE_HOME", stateHome)
t.Setenv("XDG_RUNTIME_DIR", runtimeHome)
cmd := NewBangerCommand()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stdout)
cmd.SetArgs([]string{"daemon", "status"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "stopped\n") {
t.Fatalf("output = %q, want stopped status", output)
}
if !strings.Contains(output, "log: "+filepath.Join(stateHome, "banger", "bangerd.log")) {
t.Fatalf("output = %q, want daemon log path", output)
}
if !strings.Contains(output, "dns: 127.0.0.1:42069") {
t.Fatalf("output = %q, want dns listener", output)
}
if !strings.Contains(output, "web: http://127.0.0.1:7777") {
t.Fatalf("output = %q, want default web listener", output)
}
}
func TestDaemonStatusIncludesDaemonBuildInfoWhenRunning(t *testing.T) {
origDaemonPing := daemonPingFunc
t.Cleanup(func() {
daemonPingFunc = origDaemonPing
})
configHome := filepath.Join(t.TempDir(), "config")
stateHome := filepath.Join(t.TempDir(), "state")
runtimeHome := filepath.Join(t.TempDir(), "runtime")
t.Setenv("XDG_CONFIG_HOME", configHome)
t.Setenv("XDG_STATE_HOME", stateHome)
t.Setenv("XDG_RUNTIME_DIR", runtimeHome)
daemonPingFunc = func(context.Context, string) (api.PingResult, error) {
return api.PingResult{
Status: "ok",
PID: 42,
WebURL: "http://127.0.0.1:7777",
Version: "v1.2.3",
Commit: "abc123",
BuiltAt: "2026-03-22T12:00:00Z",
}, nil
}
cmd := NewBangerCommand()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stdout)
cmd.SetArgs([]string{"daemon", "status"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
output := stdout.String()
for _, want := range []string{
"running\n",
"pid: 42",
"version: v1.2.3",
"commit: abc123",
"built_at: 2026-03-22T12:00:00Z",
"log: " + filepath.Join(stateHome, "banger", "bangerd.log"),
"web: http://127.0.0.1:7777",
} {
if !strings.Contains(output, want) {
t.Fatalf("output = %q, want %q", output, want)
}
}
}
func TestBuildDaemonCommandIsDetachedFromCallerContext(t *testing.T) {
cmd := buildDaemonCommand("/tmp/bangerd")
if cmd.Path != "/tmp/bangerd" {
t.Fatalf("command path = %q", cmd.Path)
}
if cmd.Cancel != nil {
t.Fatal("daemon process should not be tied to a CLI request context")
}
}
func TestAbsolutizeImageBuildPaths(t *testing.T) {
dir := t.TempDir()
prev, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(prev)
})
params := api.ImageBuildParams{
FromImage: "base-image",
KernelPath: "/kernel",
InitrdPath: "boot/initrd.img",
ModulesDir: "modules",
}
if err := absolutizeImageBuildPaths(&params); err != nil {
t.Fatalf("absolutizeImageBuildPaths: %v", err)
}
want := api.ImageBuildParams{
FromImage: "base-image",
KernelPath: "/kernel",
InitrdPath: filepath.Join(dir, "boot/initrd.img"),
ModulesDir: filepath.Join(dir, "modules"),
}
if !reflect.DeepEqual(params, want) {
t.Fatalf("params = %+v, want %+v", params, want)
}
}
func testCLIResolvedVM(id, name string) model.VMRecord {
return model.VMRecord{ID: id, Name: name}
}
func testRunGit(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-c", "commit.gpgsign=false", "-C", dir}, args...)...)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, string(output))
}
return string(output)
}
type testVMRunUpload struct {
path string
mode os.FileMode
data []byte
}
type testVMRunGuestClient struct {
closed bool
uploads []testVMRunUpload
uploadPath string
uploadMode os.FileMode
uploadData []byte
uploadErr error
checkoutErr error
launchErr error
script string
launchScript string
runScriptCalls int
tarSourceDir string
tarCommand string
streamSourceDir string
streamEntries []string
streamCommand string
}
func (c *testVMRunGuestClient) Close() error {
c.closed = true
return nil
}
func (c *testVMRunGuestClient) UploadFile(ctx context.Context, remotePath string, mode os.FileMode, data []byte, logWriter io.Writer) error {
copyData := append([]byte(nil), data...)
c.uploads = append(c.uploads, testVMRunUpload{path: remotePath, mode: mode, data: copyData})
c.uploadPath = remotePath
c.uploadMode = mode
c.uploadData = copyData
return c.uploadErr
}
func (c *testVMRunGuestClient) StreamTar(ctx context.Context, sourceDir, remoteCommand string, logWriter io.Writer) error {
c.tarSourceDir = sourceDir
c.tarCommand = remoteCommand
return nil
}
func (c *testVMRunGuestClient) RunScript(ctx context.Context, script string, logWriter io.Writer) error {
c.runScriptCalls++
switch c.runScriptCalls {
case 1:
c.script = script
return c.checkoutErr
default:
c.launchScript = script
return c.launchErr
}
}
func (c *testVMRunGuestClient) StreamTarEntries(ctx context.Context, sourceDir string, entries []string, remoteCommand string, logWriter io.Writer) error {
c.streamSourceDir = sourceDir
c.streamEntries = append([]string(nil), entries...)
c.streamCommand = remoteCommand
return nil
}