package cli import ( "bytes" "context" "io" "os" "path/filepath" "strings" "testing" "time" "banger/internal/api" "banger/internal/model" "banger/internal/toolingplan" ) func TestVMRunRejectsDetachWithRm(t *testing.T) { cmd := NewBangerCommand() cmd.SetArgs([]string{"vm", "run", "-d", "--rm"}) err := cmd.Execute() if err == nil || !strings.Contains(err.Error(), "cannot combine --detach with --rm") { t.Fatalf("Execute() error = %v, want --detach + --rm rejection", err) } } func TestVMRunRejectsDetachWithCommand(t *testing.T) { cmd := NewBangerCommand() cmd.SetArgs([]string{"vm", "run", "-d", "--", "whoami"}) err := cmd.Execute() if err == nil || !strings.Contains(err.Error(), "cannot combine --detach with a guest command") { t.Fatalf("Execute() error = %v, want --detach + command rejection", err) } } func TestRepoHasMiseFiles(t *testing.T) { dir := t.TempDir() got, err := repoHasMiseFiles(dir) if err != nil { t.Fatalf("repoHasMiseFiles(empty): %v", err) } if got { t.Fatalf("repoHasMiseFiles(empty) = true, want false") } if err := os.WriteFile(filepath.Join(dir, ".mise.toml"), []byte(""), 0o600); err != nil { t.Fatalf("write .mise.toml: %v", err) } got, err = repoHasMiseFiles(dir) if err != nil { t.Fatalf("repoHasMiseFiles(.mise.toml): %v", err) } if !got { t.Fatalf("repoHasMiseFiles(.mise.toml) = false, want true") } dir2 := t.TempDir() if err := os.WriteFile(filepath.Join(dir2, ".tool-versions"), []byte(""), 0o600); err != nil { t.Fatalf("write .tool-versions: %v", err) } got, err = repoHasMiseFiles(dir2) if err != nil { t.Fatalf("repoHasMiseFiles(.tool-versions): %v", err) } if !got { t.Fatalf("repoHasMiseFiles(.tool-versions) = false, want true") } } // runVMRunDepsRunningVM returns a deps wired so runVMRun reaches a // point where it would create a VM and proceed — used by precondition // tests that should refuse before any of these fakes get called. func runVMRunDepsRunningVM(t *testing.T) (*deps, *model.VMRecord) { t.Helper() d := defaultDeps() vm := &model.VMRecord{ ID: "vm-id", Name: "devbox", Runtime: model.VMRuntime{ State: model.VMStateRunning, GuestIP: "172.16.0.2", DNSName: "devbox.vm", }, } d.vmCreateBegin = 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 } d.guestWaitForSSH = func(context.Context, string, string, time.Duration) error { return nil } d.vmWorkspacePrepare = func(context.Context, string, api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) { return api.VMWorkspacePrepareResult{Workspace: model.WorkspacePrepareResult{VMID: vm.ID, GuestPath: "/root/repo", RepoName: "repo", RepoRoot: "/tmp/repo"}}, nil } d.buildVMRunToolingPlan = func(context.Context, string) toolingplan.Plan { return toolingplan.Plan{} } d.vmHealth = func(context.Context, string, string) (api.VMHealthResult, error) { return api.VMHealthResult{Healthy: true}, nil } d.sshExec = func(context.Context, io.Reader, io.Writer, io.Writer, []string) error { return nil } return d, vm } func TestRunVMRunRefusesBootstrapWithoutNAT(t *testing.T) { repoRoot := t.TempDir() if err := os.WriteFile(filepath.Join(repoRoot, ".mise.toml"), []byte(""), 0o600); err != nil { t.Fatalf("write .mise.toml: %v", err) } d := defaultDeps() d.vmCreateBegin = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) { t.Fatal("vmCreateBegin should not be called when NAT precondition refuses") return api.VMCreateBeginResult{}, nil } repo := vmRunRepo{sourcePath: repoRoot} var stdout, stderr bytes.Buffer err := d.runVMRun( context.Background(), "/tmp/bangerd.sock", model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"}, strings.NewReader(""), &stdout, &stderr, api.VMCreateParams{Name: "devbox", NATEnabled: false}, &repo, nil, false, false, false, ) if err == nil || !strings.Contains(err.Error(), "tooling bootstrap requires --nat") { t.Fatalf("runVMRun = %v, want NAT precondition refusal", err) } } func TestRunVMRunBootstrapPreconditionRespectsNoBootstrap(t *testing.T) { repoRoot := t.TempDir() if err := os.WriteFile(filepath.Join(repoRoot, ".mise.toml"), []byte(""), 0o600); err != nil { t.Fatalf("write .mise.toml: %v", err) } d, _ := runVMRunDepsRunningVM(t) dialed := false d.guestDial = func(context.Context, string, string) (vmRunGuestClient, error) { dialed = true return &testVMRunGuestClient{}, nil } repo := vmRunRepo{sourcePath: repoRoot} var stdout, stderr bytes.Buffer err := d.runVMRun( context.Background(), "/tmp/bangerd.sock", model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"}, strings.NewReader(""), &stdout, &stderr, api.VMCreateParams{Name: "devbox", NATEnabled: false}, &repo, nil, false, false, true, // skipBootstrap = true ) if err != nil { t.Fatalf("runVMRun: %v", err) } if dialed { t.Fatal("guestDial should not be called when --no-bootstrap is set") } } func TestRunVMRunBootstrapPreconditionPassesWithoutMiseFiles(t *testing.T) { repoRoot := t.TempDir() // empty repo, no mise files d, _ := runVMRunDepsRunningVM(t) dialed := false d.guestDial = func(context.Context, string, string) (vmRunGuestClient, error) { dialed = true return &testVMRunGuestClient{}, nil } repo := vmRunRepo{sourcePath: repoRoot} var stdout, stderr bytes.Buffer err := d.runVMRun( context.Background(), "/tmp/bangerd.sock", model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"}, strings.NewReader(""), &stdout, &stderr, api.VMCreateParams{Name: "devbox", NATEnabled: false}, &repo, nil, false, false, false, ) if err != nil { t.Fatalf("runVMRun: %v", err) } // Bootstrap dispatch happens (no mise file gating) but dial still // gets called because the harness pipeline runs. if !dialed { t.Fatal("guestDial should be called for bootstrap dispatch") } } func TestRunVMRunDetachSkipsSshAttach(t *testing.T) { d, _ := runVMRunDepsRunningVM(t) d.guestDial = func(context.Context, string, string) (vmRunGuestClient, error) { return &testVMRunGuestClient{}, nil } sshExecCalls := 0 d.sshExec = func(context.Context, io.Reader, io.Writer, io.Writer, []string) error { sshExecCalls++ return nil } var stdout, stderr bytes.Buffer err := d.runVMRun( context.Background(), "/tmp/bangerd.sock", model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"}, strings.NewReader(""), &stdout, &stderr, api.VMCreateParams{Name: "devbox"}, nil, // bare mode nil, // no command false, true, false, // detach = true ) if err != nil { t.Fatalf("runVMRun: %v", err) } if sshExecCalls != 0 { t.Fatalf("sshExec called %d times, want 0 in detach mode", sshExecCalls) } if !strings.Contains(stderr.String(), "reconnect with: banger vm ssh devbox") { t.Fatalf("stderr = %q, want reconnect hint", stderr.String()) } } func TestRunVMRunDetachUsesSyncBootstrapPath(t *testing.T) { repoRoot := t.TempDir() d, _ := runVMRunDepsRunningVM(t) fakeClient := &testVMRunGuestClient{} d.guestDial = func(context.Context, string, string) (vmRunGuestClient, error) { return fakeClient, nil } sshExecCalls := 0 d.sshExec = func(context.Context, io.Reader, io.Writer, io.Writer, []string) error { sshExecCalls++ return nil } repo := vmRunRepo{sourcePath: repoRoot} var stdout, stderr bytes.Buffer err := d.runVMRun( context.Background(), "/tmp/bangerd.sock", model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"}, strings.NewReader(""), &stdout, &stderr, api.VMCreateParams{Name: "devbox", NATEnabled: true}, &repo, nil, false, true, false, // detach = true ) if err != nil { t.Fatalf("runVMRun: %v", err) } if sshExecCalls != 0 { t.Fatalf("sshExec called %d times, want 0 in detach mode", sshExecCalls) } if len(fakeClient.uploads) != 1 { t.Fatalf("uploads = %d, want 1 (harness upload)", len(fakeClient.uploads)) } // Sync mode should invoke the tee'd wrapper, not the nohup launcher. if strings.Contains(fakeClient.launchScript, "nohup") { t.Fatalf("detach mode should not use nohup launcher; got: %q", fakeClient.launchScript) } if !strings.Contains(fakeClient.launchScript, "tee") { t.Fatalf("detach mode should tee output to log; got: %q", fakeClient.launchScript) } }