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" "github.com/spf13/cobra" ) 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", "kernel", "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 TestImageCommandIncludesPull(t *testing.T) { cmd := NewBangerCommand() var image *cobra.Command for _, sub := range cmd.Commands() { if sub.Name() == "image" { image = sub break } } if image == nil { t.Fatalf("image command missing from root") } hasPull := false for _, sub := range image.Commands() { if sub.Name() == "pull" { hasPull = true if flag := sub.Flags().Lookup("kernel-ref"); flag == nil { t.Errorf("image pull missing --kernel-ref flag") } if flag := sub.Flags().Lookup("size"); flag == nil { t.Errorf("image pull missing --size flag") } } } if !hasPull { t.Fatalf("image pull subcommand missing") } } func TestKernelCommandExposesSubcommands(t *testing.T) { cmd := NewBangerCommand() var kernel *cobra.Command for _, sub := range cmd.Commands() { if sub.Name() == "kernel" { kernel = sub break } } if kernel == nil { t.Fatalf("kernel command missing from root") } names := []string{} for _, sub := range kernel.Commands() { names = append(names, sub.Name()) } want := []string{"import", "list", "pull", "rm", "show"} if !reflect.DeepEqual(names, want) { t.Fatalf("kernel subcommands = %v, want %v", names, 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 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 ") { 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(¶ms); 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"}, false); 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"}, false) 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"}, false) 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 TestRunVMRunWorkspacePreparesAndAttaches(t *testing.T) { repoRoot := t.TempDir() origBegin := vmCreateBeginFunc origStatus := vmCreateStatusFunc origCancel := vmCreateCancelFunc origWaitForSSH := guestWaitForSSHFunc origGuestDial := guestDialFunc origBuildVMRunToolingPlan := buildVMRunToolingPlanFunc origVMWorkspacePrepare := vmWorkspacePrepareFunc origSSHExec := sshExecFunc origHealth := vmHealthFunc t.Cleanup(func() { vmCreateBeginFunc = origBegin vmCreateStatusFunc = origStatus vmCreateCancelFunc = origCancel guestWaitForSSHFunc = origWaitForSSH guestDialFunc = origGuestDial buildVMRunToolingPlanFunc = origBuildVMRunToolingPlan vmWorkspacePrepareFunc = origVMWorkspacePrepare sshExecFunc = origSSHExec vmHealthFunc = origHealth }) 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{} 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 fakeClient, nil } var workspaceParams api.VMWorkspacePrepareParams vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) { workspaceParams = params return api.VMWorkspacePrepareResult{Workspace: model.WorkspacePrepareResult{VMID: vm.ID, GuestPath: "/root/repo"}}, nil } buildVMRunToolingPlanFunc = func(context.Context, string) toolingplan.Plan { return toolingplan.Plan{ RepoManagedTools: []string{"go"}, Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}}, } } var sshArgsSeen []string sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error { sshArgsSeen = args return nil } vmHealthFunc = func(context.Context, string, string) (api.VMHealthResult, error) { return api.VMHealthResult{Name: "devbox", Healthy: false}, nil } spec := vmRunRepoSpec{ SourcePath: repoRoot, RepoRoot: repoRoot, RepoName: "repo", HeadCommit: "deadbeef", CurrentBranch: "main", } var stdout, stderr bytes.Buffer err := runVMRun( context.Background(), "/tmp/bangerd.sock", model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"}, strings.NewReader(""), &stdout, &stderr, api.VMCreateParams{Name: "devbox"}, &spec, nil, false, ) if err != nil { t.Fatalf("runVMRun: %v", err) } if workspaceParams.IDOrName != "devbox" || workspaceParams.SourcePath != repoRoot { t.Fatalf("workspaceParams = %+v", workspaceParams) } if len(fakeClient.uploads) != 1 { t.Fatalf("uploads = %d, want tooling harness upload", len(fakeClient.uploads)) } if !fakeClient.closed { t.Fatal("guest client should be closed after tooling bootstrap") } if len(sshArgsSeen) == 0 || sshArgsSeen[len(sshArgsSeen)-1] != "root@172.16.0.2" { t.Fatalf("sshArgsSeen = %v, want interactive ssh to 172.16.0.2 (no trailing command)", sshArgsSeen) } if got := stdout.String(); strings.Contains(got, "VM ready.") { t.Fatalf("stdout = %q, want no next-steps block", got) } } func TestVMRunPrintsPostCreateProgress(t *testing.T) { origBegin := vmCreateBeginFunc origStatus := vmCreateStatusFunc origCancel := vmCreateCancelFunc origWaitForSSH := guestWaitForSSHFunc origGuestDial := guestDialFunc origVMWorkspacePrepare := vmWorkspacePrepareFunc origSSHExec := sshExecFunc origHealth := vmHealthFunc t.Cleanup(func() { vmCreateBeginFunc = origBegin vmCreateStatusFunc = origStatus vmCreateCancelFunc = origCancel guestWaitForSSHFunc = origWaitForSSH guestDialFunc = origGuestDial vmWorkspacePrepareFunc = origVMWorkspacePrepare sshExecFunc = origSSHExec vmHealthFunc = origHealth }) 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 } vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) { return api.VMWorkspacePrepareResult{Workspace: model.WorkspacePrepareResult{VMID: vm.ID, GuestPath: "/root/repo"}}, nil } sshExecFunc = func(context.Context, io.Reader, io.Writer, io.Writer, []string) error { return nil } vmHealthFunc = func(context.Context, string, string) (api.VMHealthResult, error) { return api.VMHealthResult{Name: "devbox", Healthy: false}, nil } spec := vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"} var stdout, stderr bytes.Buffer err := runVMRun( context.Background(), "/tmp/bangerd.sock", model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"}, strings.NewReader(""), &stdout, &stderr, api.VMCreateParams{Name: "devbox"}, &spec, nil, false, ) if err != nil { t.Fatalf("runVMRun: %v", err) } output := stderr.String() for _, want := range []string{ "[vm run] waiting for guest ssh", "[vm run] preparing guest workspace", "[vm run] starting guest tooling bootstrap", "[vm run] guest tooling log: /root/.cache/banger/vm-run-tooling-repo.log", "[vm run] attaching to guest", } { if !strings.Contains(output, want) { t.Fatalf("stderr = %q, want %q", output, want) } } if strings.Contains(output, "[vm run] printing next steps") { t.Fatalf("stderr = %q, should not print next-steps progress", output) } } func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) { origBegin := vmCreateBeginFunc origStatus := vmCreateStatusFunc origCancel := vmCreateCancelFunc origWaitForSSH := guestWaitForSSHFunc origGuestDial := guestDialFunc origVMWorkspacePrepare := vmWorkspacePrepareFunc origSSHExec := sshExecFunc origHealth := vmHealthFunc t.Cleanup(func() { vmCreateBeginFunc = origBegin vmCreateStatusFunc = origStatus vmCreateCancelFunc = origCancel guestWaitForSSHFunc = origWaitForSSH guestDialFunc = origGuestDial vmWorkspacePrepareFunc = origVMWorkspacePrepare sshExecFunc = origSSHExec vmHealthFunc = origHealth }) 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 } vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) { return api.VMWorkspacePrepareResult{Workspace: model.WorkspacePrepareResult{VMID: vm.ID, GuestPath: "/root/repo"}}, nil } sshExecCalls := 0 sshExecFunc = func(context.Context, io.Reader, io.Writer, io.Writer, []string) error { sshExecCalls++ return nil } vmHealthFunc = func(context.Context, string, string) (api.VMHealthResult, error) { return api.VMHealthResult{Healthy: false}, nil } spec := vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"} var stdout, stderr bytes.Buffer err := runVMRun( context.Background(), "/tmp/bangerd.sock", model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"}, strings.NewReader(""), &stdout, &stderr, api.VMCreateParams{Name: "devbox"}, &spec, nil, false, ) if err != nil { t.Fatalf("runVMRun: %v", err) } if !strings.Contains(stderr.String(), "[vm run] warning: guest tooling bootstrap start failed: launch guest tooling bootstrap") { t.Fatalf("stderr = %q, want tooling bootstrap warning", stderr.String()) } if sshExecCalls != 1 { t.Fatalf("sshExec calls = %d, want 1 (interactive attach still runs)", sshExecCalls) } } func TestRunVMRunBareModeSkipsWorkspaceAndTooling(t *testing.T) { origBegin := vmCreateBeginFunc origWaitForSSH := guestWaitForSSHFunc origGuestDial := guestDialFunc origVMWorkspacePrepare := vmWorkspacePrepareFunc origSSHExec := sshExecFunc origHealth := vmHealthFunc t.Cleanup(func() { vmCreateBeginFunc = origBegin guestWaitForSSHFunc = origWaitForSSH guestDialFunc = origGuestDial vmWorkspacePrepareFunc = origVMWorkspacePrepare sshExecFunc = origSSHExec vmHealthFunc = origHealth }) vm := model.VMRecord{ ID: "vm-id", Name: "bare", 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", Done: true, Success: true, VM: &vm}}, nil } guestWaitForSSHFunc = func(context.Context, string, string, time.Duration) error { return nil } guestDialFunc = func(context.Context, string, string) (vmRunGuestClient, error) { t.Fatal("guestDialFunc should not be called in bare mode") return nil, nil } vmWorkspacePrepareFunc = func(context.Context, string, api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) { t.Fatal("vmWorkspacePrepareFunc should not be called in bare mode") return api.VMWorkspacePrepareResult{}, nil } sshExecCalls := 0 sshExecFunc = func(context.Context, io.Reader, io.Writer, io.Writer, []string) error { sshExecCalls++ return nil } vmHealthFunc = func(context.Context, string, string) (api.VMHealthResult, error) { return api.VMHealthResult{Healthy: false}, nil } var stdout, stderr bytes.Buffer err := runVMRun( context.Background(), "/tmp/bangerd.sock", model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"}, strings.NewReader(""), &stdout, &stderr, api.VMCreateParams{Name: "bare"}, nil, nil, false, ) if err != nil { t.Fatalf("runVMRun: %v", err) } if sshExecCalls != 1 { t.Fatalf("sshExec calls = %d, want 1", sshExecCalls) } if !strings.Contains(stderr.String(), "[vm run] attaching to guest") { t.Fatalf("stderr = %q, want attach progress", stderr.String()) } } func TestRunVMRunRMDeletesAfterSessionExits(t *testing.T) { origBegin := vmCreateBeginFunc origWaitForSSH := guestWaitForSSHFunc origSSHExec := sshExecFunc origHealth := vmHealthFunc origDelete := vmDeleteFunc t.Cleanup(func() { vmCreateBeginFunc = origBegin guestWaitForSSHFunc = origWaitForSSH sshExecFunc = origSSHExec vmHealthFunc = origHealth vmDeleteFunc = origDelete }) vm := model.VMRecord{ ID: "vm-id", Name: "tmpbox", 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", Done: true, Success: true, VM: &vm}}, nil } guestWaitForSSHFunc = func(context.Context, string, string, time.Duration) error { return nil } sshExecFunc = func(context.Context, io.Reader, io.Writer, io.Writer, []string) error { return nil } vmHealthFunc = func(context.Context, string, string) (api.VMHealthResult, error) { return api.VMHealthResult{Healthy: false}, nil } deletedRef := "" vmDeleteFunc = func(_ context.Context, _, idOrName string) error { deletedRef = idOrName return nil } var stdout, stderr bytes.Buffer err := runVMRun( context.Background(), "/tmp/bangerd.sock", model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"}, strings.NewReader(""), &stdout, &stderr, api.VMCreateParams{Name: "tmpbox"}, nil, nil, true, // --rm ) if err != nil { t.Fatalf("runVMRun: %v", err) } if deletedRef != "tmpbox" { t.Fatalf("deletedRef = %q, want tmpbox", deletedRef) } // The "VM is still running" reminder would be misleading when // the VM is about to be deleted; it must be suppressed. if strings.Contains(stderr.String(), "is still running") { t.Fatalf("stderr = %q, should not print still-running reminder under --rm", stderr.String()) } } func TestRunVMRunRMSkipsDeleteOnSSHWaitTimeout(t *testing.T) { origBegin := vmCreateBeginFunc origWaitForSSH := guestWaitForSSHFunc origDelete := vmDeleteFunc origTimeout := vmRunSSHTimeout vmRunSSHTimeout = 50 * time.Millisecond t.Cleanup(func() { vmCreateBeginFunc = origBegin guestWaitForSSHFunc = origWaitForSSH vmDeleteFunc = origDelete vmRunSSHTimeout = origTimeout }) vm := model.VMRecord{ ID: "vm-id", Name: "slowvm", 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", Done: true, Success: true, VM: &vm}}, nil } guestWaitForSSHFunc = func(ctx context.Context, _, _ string, _ time.Duration) error { <-ctx.Done() return ctx.Err() } deleteCalled := false vmDeleteFunc = func(context.Context, string, string) error { deleteCalled = true return nil } var stdout, stderr bytes.Buffer err := runVMRun( context.Background(), "/tmp/bangerd.sock", model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"}, strings.NewReader(""), &stdout, &stderr, api.VMCreateParams{Name: "slowvm"}, nil, nil, true, // --rm ) if err == nil { t.Fatal("want timeout error") } if deleteCalled { t.Fatal("VM should NOT be deleted on ssh-wait timeout even with --rm (keep for debugging)") } } func TestRunVMRunSSHTimeoutReturnsActionableError(t *testing.T) { origBegin := vmCreateBeginFunc origWaitForSSH := guestWaitForSSHFunc origTimeout := vmRunSSHTimeout vmRunSSHTimeout = 50 * time.Millisecond t.Cleanup(func() { vmCreateBeginFunc = origBegin guestWaitForSSHFunc = origWaitForSSH vmRunSSHTimeout = origTimeout }) vm := model.VMRecord{ ID: "vm-id", Name: "slowvm", 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", Done: true, Success: true, VM: &vm}}, nil } // Simulate the guest never bringing sshd up — the wait-for-ssh // child context fires its deadline, returning a DeadlineExceeded. guestWaitForSSHFunc = func(ctx context.Context, _, _ string, _ time.Duration) error { <-ctx.Done() return ctx.Err() } var stdout, stderr bytes.Buffer err := runVMRun( context.Background(), "/tmp/bangerd.sock", model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"}, strings.NewReader(""), &stdout, &stderr, api.VMCreateParams{Name: "slowvm"}, nil, nil, false, ) if err == nil { t.Fatal("want timeout error") } msg := err.Error() for _, want := range []string{ "slowvm", "did not come up", "banger vm logs slowvm", "banger vm delete slowvm", } { if !strings.Contains(msg, want) { t.Fatalf("err = %q, want contains %q", msg, want) } } } func TestRunVMRunCommandModePropagatesExitCode(t *testing.T) { origBegin := vmCreateBeginFunc origWaitForSSH := guestWaitForSSHFunc origVMWorkspacePrepare := vmWorkspacePrepareFunc origSSHExec := sshExecFunc t.Cleanup(func() { vmCreateBeginFunc = origBegin guestWaitForSSHFunc = origWaitForSSH vmWorkspacePrepareFunc = origVMWorkspacePrepare sshExecFunc = origSSHExec }) vm := model.VMRecord{ ID: "vm-id", Name: "cmdbox", 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", Done: true, Success: true, VM: &vm}}, nil } guestWaitForSSHFunc = func(context.Context, string, string, time.Duration) error { return nil } vmWorkspacePrepareFunc = func(context.Context, string, api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) { t.Fatal("workspace prepare should not run without spec") return api.VMWorkspacePrepareResult{}, nil } var sshArgsSeen []string sshExecFunc = func(_ context.Context, _ io.Reader, _, _ io.Writer, args []string) error { sshArgsSeen = args return exitErrorWithCode(t, 7) } var stdout, stderr bytes.Buffer err := runVMRun( context.Background(), "/tmp/bangerd.sock", model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"}, strings.NewReader(""), &stdout, &stderr, api.VMCreateParams{Name: "cmdbox"}, nil, []string{"false"}, false, ) var exitErr ExitCodeError if !errors.As(err, &exitErr) || exitErr.Code != 7 { t.Fatalf("runVMRun error = %v, want ExitCodeError{7}", err) } if len(sshArgsSeen) == 0 || sshArgsSeen[len(sshArgsSeen)-1] != "false" { t.Fatalf("sshArgsSeen = %v, want trailing command 'false'", sshArgsSeen) } if !strings.Contains(stderr.String(), "[vm run] running command in guest") { t.Fatalf("stderr = %q, want command progress", stderr.String()) } } func TestVMRunCommandRejectsBranchWithoutPath(t *testing.T) { cmd := NewBangerCommand() cmd.SetArgs([]string{"vm", "run", "--branch", "feat"}) cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&bytes.Buffer{}) err := cmd.Execute() if err == nil || !strings.Contains(err.Error(), "--branch requires a path") { t.Fatalf("Execute() error = %v, want --branch requires a path", err) } } func TestSplitVMRunArgsPartitionsOnDash(t *testing.T) { cases := []struct { name string argv []string wantPath []string wantCmd []string }{ {"empty", []string{}, []string{}, nil}, {"path only", []string{"./repo"}, []string{"./repo"}, nil}, {"cmd only", []string{"--", "make", "test"}, []string{}, []string{"make", "test"}}, {"path and cmd", []string{"./repo", "--", "ls"}, []string{"./repo"}, []string{"ls"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { // Parse through cobra so ArgsLenAtDash is populated. var seenPath, seenCmd []string root := &cobra.Command{Use: "root"} run := &cobra.Command{ Use: "run", Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { seenPath, seenCmd = splitVMRunArgs(cmd, args) return nil }, } root.AddCommand(run) root.SetArgs(append([]string{"run"}, tc.argv...)) root.SetOut(&bytes.Buffer{}) root.SetErr(&bytes.Buffer{}) if err := root.Execute(); err != nil { t.Fatalf("execute: %v", err) } if len(seenPath) != len(tc.wantPath) { t.Fatalf("path = %v, want %v", seenPath, tc.wantPath) } for i := range seenPath { if seenPath[i] != tc.wantPath[i] { t.Fatalf("path = %v, want %v", seenPath, tc.wantPath) } } if len(seenCmd) != len(tc.wantCmd) { t.Fatalf("cmd = %v, want %v", seenCmd, tc.wantCmd) } for i := range seenCmd { if seenCmd[i] != tc.wantCmd[i] { t.Fatalf("cmd = %v, want %v", seenCmd, tc.wantCmd) } } }) } } func TestVMRunToolingHarnessScriptUsesMiseOnly(t *testing.T) { script := vmRunToolingHarnessScript(vmRunRepoSpec{RepoName: "repo"}, toolingplan.Plan{ RepoManagedTools: []string{"node"}, 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{ `repo-managed mise tools: node`, `run_best_effort "$MISE_BIN" install`, `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`, } { if !strings.Contains(script, want) { t.Fatalf("script = %q, want %q", script, want) } } for _, unwanted := range []string{`opencode run`, `PROMPT_FILE=`, `--format json`, `mimo-v2-pro-free`} { 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 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++ if c.runScriptCalls == 1 { c.script = script c.launchScript = script if c.checkoutErr != nil { return c.checkoutErr } return c.launchErr } 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 } func TestVMSessionSendCommandExists(t *testing.T) { root := NewBangerCommand() vm, _, err := root.Find([]string{"vm"}) if err != nil { t.Fatalf("find vm: %v", err) } session, _, err := vm.Find([]string{"session"}) if err != nil { t.Fatalf("find session: %v", err) } if _, _, err := session.Find([]string{"send"}); err != nil { t.Fatalf("find session send: %v", err) } } func TestVMSessionSendRejectsWrongArgCount(t *testing.T) { cmd := NewBangerCommand() cmd.SetArgs([]string{"vm", "session", "send", "only-one-arg"}) err := cmd.Execute() if err == nil || !strings.Contains(err.Error(), "usage: banger vm session send") { t.Fatalf("Execute() error = %v, want send usage error", err) } } func stubEnsureDaemonForSend(t *testing.T) { t.Helper() t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config")) t.Setenv("XDG_STATE_HOME", filepath.Join(t.TempDir(), "state")) t.Setenv("XDG_RUNTIME_DIR", filepath.Join(t.TempDir(), "run")) origPing := daemonPingFunc t.Cleanup(func() { daemonPingFunc = origPing }) daemonPingFunc = func(context.Context, string) (api.PingResult, error) { return api.PingResult{Status: "ok", PID: os.Getpid()}, nil } } func TestVMSessionSendWithMessageFlag(t *testing.T) { stubEnsureDaemonForSend(t) original := guestSessionSendFunc t.Cleanup(func() { guestSessionSendFunc = original }) var capturedParams api.GuestSessionSendParams guestSessionSendFunc = func(_ context.Context, _ string, params api.GuestSessionSendParams) (api.GuestSessionSendResult, error) { capturedParams = params return api.GuestSessionSendResult{ Session: model.GuestSession{ID: "sess-id", Name: "planner"}, BytesWritten: len(params.Payload), }, nil } cmd := NewBangerCommand() var out bytes.Buffer cmd.SetOut(&out) cmd.SetArgs([]string{"vm", "session", "send", "devbox", "planner", "--message", `{"type":"abort"}`}) if err := cmd.Execute(); err != nil { t.Fatalf("Execute: %v", err) } wantPayload := []byte(`{"type":"abort"}` + "\n") if string(capturedParams.Payload) != string(wantPayload) { t.Fatalf("payload = %q, want %q", capturedParams.Payload, wantPayload) } if capturedParams.VMIDOrName != "devbox" { t.Fatalf("VMIDOrName = %q, want %q", capturedParams.VMIDOrName, "devbox") } if capturedParams.SessionIDOrName != "planner" { t.Fatalf("SessionIDOrName = %q, want %q", capturedParams.SessionIDOrName, "planner") } if !strings.Contains(out.String(), "17") { t.Fatalf("output = %q, want bytes_written in output", out.String()) } } func TestVMSessionSendMessageAlreadyHasNewline(t *testing.T) { stubEnsureDaemonForSend(t) original := guestSessionSendFunc t.Cleanup(func() { guestSessionSendFunc = original }) var capturedPayload []byte guestSessionSendFunc = func(_ context.Context, _ string, params api.GuestSessionSendParams) (api.GuestSessionSendResult, error) { capturedPayload = params.Payload return api.GuestSessionSendResult{ Session: model.GuestSession{Name: "s"}, BytesWritten: len(params.Payload), }, nil } cmd := NewBangerCommand() cmd.SetOut(io.Discard) cmd.SetArgs([]string{"vm", "session", "send", "devbox", "s", "--message", "{\"type\":\"abort\"}\n"}) if err := cmd.Execute(); err != nil { t.Fatalf("Execute: %v", err) } // Must not double-append newline. if capturedPayload[len(capturedPayload)-1] != '\n' { t.Fatalf("payload missing trailing newline: %q", capturedPayload) } if len(capturedPayload) > 0 && capturedPayload[len(capturedPayload)-2] == '\n' { t.Fatalf("payload has double trailing newline: %q", capturedPayload) } } func TestVMSessionSendFromStdin(t *testing.T) { stubEnsureDaemonForSend(t) original := guestSessionSendFunc t.Cleanup(func() { guestSessionSendFunc = original }) var capturedPayload []byte guestSessionSendFunc = func(_ context.Context, _ string, params api.GuestSessionSendParams) (api.GuestSessionSendResult, error) { capturedPayload = params.Payload return api.GuestSessionSendResult{ Session: model.GuestSession{Name: "planner"}, BytesWritten: len(params.Payload), }, nil } stdinPayload := `{"type":"steer","message":"Focus on src/"}` + "\n" cmd := NewBangerCommand() cmd.SetOut(io.Discard) cmd.SetIn(strings.NewReader(stdinPayload)) cmd.SetArgs([]string{"vm", "session", "send", "devbox", "planner"}) if err := cmd.Execute(); err != nil { t.Fatalf("Execute: %v", err) } if string(capturedPayload) != stdinPayload { t.Fatalf("payload = %q, want %q", capturedPayload, stdinPayload) } } func TestVMWorkspaceExportCommandExists(t *testing.T) { root := NewBangerCommand() vm, _, err := root.Find([]string{"vm"}) if err != nil { t.Fatalf("find vm: %v", err) } workspace, _, err := vm.Find([]string{"workspace"}) if err != nil { t.Fatalf("find workspace: %v", err) } if _, _, err := workspace.Find([]string{"export"}); err != nil { t.Fatalf("find workspace export: %v", err) } } func TestVMWorkspaceExportRejectsMissingArg(t *testing.T) { cmd := NewBangerCommand() cmd.SetArgs([]string{"vm", "workspace", "export"}) err := cmd.Execute() if err == nil || !strings.Contains(err.Error(), "usage: banger vm workspace export") { t.Fatalf("Execute() error = %v, want usage error", err) } } func TestVMWorkspaceExportWritesToStdout(t *testing.T) { stubEnsureDaemonForSend(t) origExport := vmWorkspaceExportFunc t.Cleanup(func() { vmWorkspaceExportFunc = origExport }) patch := []byte("diff --git a/main.go b/main.go\nindex 0000000..1111111 100644\n") vmWorkspaceExportFunc = func(_ context.Context, _ string, params api.WorkspaceExportParams) (api.WorkspaceExportResult, error) { return api.WorkspaceExportResult{ GuestPath: params.GuestPath, Patch: patch, ChangedFiles: []string{"main.go"}, HasChanges: true, }, nil } cmd := NewBangerCommand() var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(io.Discard) cmd.SetArgs([]string{"vm", "workspace", "export", "devbox"}) if err := cmd.Execute(); err != nil { t.Fatalf("Execute: %v", err) } if !bytes.Equal(out.Bytes(), patch) { t.Fatalf("stdout = %q, want %q", out.Bytes(), patch) } } func TestVMWorkspaceExportWritesToFile(t *testing.T) { stubEnsureDaemonForSend(t) origExport := vmWorkspaceExportFunc t.Cleanup(func() { vmWorkspaceExportFunc = origExport }) patch := []byte("diff --git a/main.go b/main.go\n") vmWorkspaceExportFunc = func(_ context.Context, _ string, _ api.WorkspaceExportParams) (api.WorkspaceExportResult, error) { return api.WorkspaceExportResult{ GuestPath: "/root/repo", Patch: patch, ChangedFiles: []string{"main.go"}, HasChanges: true, }, nil } outFile := filepath.Join(t.TempDir(), "worker.diff") cmd := NewBangerCommand() cmd.SetOut(io.Discard) var stderr bytes.Buffer cmd.SetErr(&stderr) cmd.SetArgs([]string{"vm", "workspace", "export", "devbox", "--output", outFile}) if err := cmd.Execute(); err != nil { t.Fatalf("Execute: %v", err) } got, err := os.ReadFile(outFile) if err != nil { t.Fatalf("ReadFile: %v", err) } if !bytes.Equal(got, patch) { t.Fatalf("file content = %q, want %q", got, patch) } if !strings.Contains(stderr.String(), "worker.diff") { t.Fatalf("stderr = %q, want output path mentioned", stderr.String()) } } func TestVMWorkspaceExportNoChanges(t *testing.T) { stubEnsureDaemonForSend(t) origExport := vmWorkspaceExportFunc t.Cleanup(func() { vmWorkspaceExportFunc = origExport }) vmWorkspaceExportFunc = func(_ context.Context, _ string, _ api.WorkspaceExportParams) (api.WorkspaceExportResult, error) { return api.WorkspaceExportResult{ GuestPath: "/root/repo", HasChanges: false, }, nil } cmd := NewBangerCommand() var out bytes.Buffer var stderr bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&stderr) cmd.SetArgs([]string{"vm", "workspace", "export", "devbox"}) if err := cmd.Execute(); err != nil { t.Fatalf("Execute: %v", err) } if out.Len() != 0 { t.Fatalf("stdout = %q, want empty when no changes", out.String()) } if !strings.Contains(stderr.String(), "no changes") { t.Fatalf("stderr = %q, want 'no changes'", stderr.String()) } } func TestVMWorkspaceExportGuestPathFlag(t *testing.T) { stubEnsureDaemonForSend(t) origExport := vmWorkspaceExportFunc t.Cleanup(func() { vmWorkspaceExportFunc = origExport }) var capturedParams api.WorkspaceExportParams vmWorkspaceExportFunc = func(_ context.Context, _ string, params api.WorkspaceExportParams) (api.WorkspaceExportResult, error) { capturedParams = params return api.WorkspaceExportResult{HasChanges: false}, nil } cmd := NewBangerCommand() cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) cmd.SetArgs([]string{"vm", "workspace", "export", "devbox", "--guest-path", "/root/project"}) if err := cmd.Execute(); err != nil { t.Fatalf("Execute: %v", err) } if capturedParams.GuestPath != "/root/project" { t.Fatalf("GuestPath = %q, want /root/project", capturedParams.GuestPath) } if capturedParams.IDOrName != "devbox" { t.Fatalf("IDOrName = %q, want devbox", capturedParams.IDOrName) } } func TestVMWorkspaceExportBaseCommitFlag(t *testing.T) { stubEnsureDaemonForSend(t) origExport := vmWorkspaceExportFunc t.Cleanup(func() { vmWorkspaceExportFunc = origExport }) var capturedParams api.WorkspaceExportParams vmWorkspaceExportFunc = func(_ context.Context, _ string, params api.WorkspaceExportParams) (api.WorkspaceExportResult, error) { capturedParams = params return api.WorkspaceExportResult{ HasChanges: false, BaseCommit: params.BaseCommit, }, nil } cmd := NewBangerCommand() cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) cmd.SetArgs([]string{"vm", "workspace", "export", "devbox", "--base-commit", "abc1234deadbeef"}) if err := cmd.Execute(); err != nil { t.Fatalf("Execute: %v", err) } if capturedParams.BaseCommit != "abc1234deadbeef" { t.Fatalf("BaseCommit = %q, want abc1234deadbeef", capturedParams.BaseCommit) } }