Files
2026-05-27 22:12:14 -05:00

474 lines
16 KiB
Go

package bootstrap
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRunWithOSDoesNotProbePackagesBeforeWindowsUpdate(t *testing.T) {
temp := t.TempDir()
runner := &fakeRunner{}
app := &Bootstrapper{
Runner: runner,
In: strings.NewReader(""),
Out: &bytes.Buffer{},
Err: &bytes.Buffer{},
Paths: UserPaths{
Home: temp,
LocalAppData: filepath.Join(temp, "AppData", "Local"),
Documents: filepath.Join(temp, "Documents"),
},
}
if err := app.Run(context.Background(), Options{Yes: true, NoWSL: true, Headless: true}); err != nil {
t.Fatal(err)
}
update := WindowsUpdateCommand()
runner.mu.Lock()
defer runner.mu.Unlock()
if len(runner.calls) == 0 {
t.Fatal("expected runner calls")
}
if got, want := runner.calls[0], commandKey(update.Name, update.Args...); got != want {
t.Fatalf("first command = %q, want Windows Update command %q", got, want)
}
}
func TestRunWithDefaultsCanDryRunOnlyOS(t *testing.T) {
temp := t.TempDir()
app := &Bootstrapper{Paths: testUserPaths(temp)}
if err := app.Run(context.Background(), Options{Only: PhaseOS, DryRun: true}); err != nil {
t.Fatal(err)
}
}
func TestRunStopsAfterOSRebootNotice(t *testing.T) {
temp := t.TempDir()
update := WindowsUpdateCommand()
reboot := windowsRebootProbeCommand()
restore := RestorePointCommand("bootstrap_windows_env: before OS configuration")
runner := &fakeRunner{responses: map[string]CommandResult{
commandKey(update.Name, update.Args...): {},
commandKey(reboot.Name, reboot.Args...): {Stdout: "true\n"},
}, defaultErr: true}
var out bytes.Buffer
app := &Bootstrapper{
Runner: runner,
In: strings.NewReader(""),
Out: &out,
Err: &bytes.Buffer{},
Paths: testUserPaths(temp),
}
if err := app.Run(context.Background(), Options{Yes: true, NoWSL: true, Headless: true}); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "require a reboot") {
t.Fatalf("output missing reboot notice:\n%s", out.String())
}
if runner.called(restore.Name, restore.Args...) {
t.Fatal("restore point should not run after reboot stop")
}
}
func TestNewBootstrapperInitializesDefaults(t *testing.T) {
app, err := NewBootstrapper()
if err != nil {
t.Fatal(err)
}
if app.Runner == nil || app.In == nil || app.Out == nil || app.Err == nil {
t.Fatalf("bootstrapper defaults not populated: %#v", app)
}
if app.Paths.Home == "" || app.Paths.Documents == "" {
t.Fatalf("paths not populated: %#v", app.Paths)
}
}
func TestBuildPlanWrapperAndPrintPlanShowStatuses(t *testing.T) {
pkg := Package{Name: "Git", WingetID: "Git.Git", WingetSource: "winget", Phase: PhaseHost}
action := Action{Name: "done action"}
plan := BuildPlan(context.Background(), &fakeRunner{}, Options{Only: PhaseOS})
if len(plan.Phases) != 1 || plan.Phases[0] != PhaseOS {
t.Fatalf("plan phases = %#v, want only OS", plan.Phases)
}
var out bytes.Buffer
PrintPlan(&out, Plan{
Phases: []Phase{PhaseHost, PhaseConfig, PhaseWSL},
PackageStates: map[Phase][]PackageState{
PhaseHost: {
{Package: pkg, Installed: true},
{Package: Package{Name: "Broken", WingetID: "Broken.ID"}, CheckErr: errors.New("check failed")},
},
},
ActionStates: map[Phase][]ActionState{
PhaseHost: {{Action: action, Installed: true}},
},
DeferredNativeWork: []string{"Docker Desktop"},
}, Options{LinuxReleaseRepo: "JMR-dev/bootstrap_dev_env"})
text := out.String()
for _, want := range []string{"installed", "check failed; will retry install", "done action - done", "config: deploy managed", "official Fedora WSL", "Deferred native Windows workloads"} {
if !strings.Contains(text, want) {
t.Fatalf("plan output missing %q:\n%s", want, text)
}
}
}
func TestRunAbortWritesLogWithoutExecuting(t *testing.T) {
temp := t.TempDir()
runner := &fakeRunner{defaultErr: true}
var out, errOut bytes.Buffer
app := &Bootstrapper{
Runner: runner,
In: strings.NewReader("no\n"),
Out: &out,
Err: &errOut,
Paths: testUserPaths(temp),
}
if err := app.Run(context.Background(), Options{}); err != nil {
t.Fatal(err)
}
if len(runner.calls) != 0 {
t.Fatalf("runner calls = %#v, want none after abort", runner.calls)
}
if !strings.Contains(out.String(), "Aborted.") || !strings.Contains(out.String(), "Run log:") {
t.Fatalf("output missing abort/log notice:\n%s", out.String())
}
}
func TestRunReturnsLogWriteFailure(t *testing.T) {
temp := t.TempDir()
docFile := filepath.Join(temp, "Documents")
if err := os.WriteFile(docFile, []byte("not a directory"), 0o644); err != nil {
t.Fatal(err)
}
app := &Bootstrapper{
Runner: &fakeRunner{},
In: strings.NewReader(""),
Out: &bytes.Buffer{},
Err: &bytes.Buffer{},
Paths: UserPaths{
Home: temp,
LocalAppData: filepath.Join(temp, "local"),
Documents: docFile,
},
}
if err := app.Run(context.Background(), Options{DryRun: true}); err == nil || !strings.Contains(err.Error(), "write run log") {
t.Fatalf("error = %v, want log write failure", err)
}
}
func TestRunOnlyHostSkipsOSAndExecutesHostPlan(t *testing.T) {
temp := t.TempDir()
runner := &fakeRunner{}
var out bytes.Buffer
app := &Bootstrapper{
Runner: runner,
In: strings.NewReader(""),
Out: &out,
Err: &bytes.Buffer{},
Paths: testUserPaths(temp),
}
if err := app.Run(context.Background(), Options{Only: PhaseHost, Yes: true, Headless: true}); err != nil {
t.Fatal(err)
}
update := WindowsUpdateCommand()
if runner.called(update.Name, update.Args...) {
t.Fatal("host-only run should not execute OS phase")
}
if !strings.Contains(out.String(), "Run log:") {
t.Fatalf("missing run log output:\n%s", out.String())
}
}
func TestExecuteCheckedPlanRecordsWSLNoticeAndIssue(t *testing.T) {
temp := t.TempDir()
var out, errOut bytes.Buffer
app := &Bootstrapper{
Runner: &fakeRunner{responses: map[string]CommandResult{
commandKey("wsl.exe", "--status"): {Err: errors.New("not installed")},
}},
Out: &out,
Err: &errOut,
Paths: testUserPaths(temp),
}
report := NewRunReport(Options{}, []Phase{PhaseWSL})
issues := app.executeCheckedPlan(context.Background(), Plan{Phases: []Phase{PhaseWSL}}, Options{}, report)
if err := issues.Err(); err != nil {
t.Fatal(err)
}
if len(report.Notices) != 1 || !strings.Contains(out.String(), "WSL prerequisites") {
t.Fatalf("notice/report mismatch: %#v\n%s", report.Notices, out.String())
}
app.Runner = &fakeRunner{responses: map[string]CommandResult{
commandKey("wsl.exe", "--status"): {Err: errors.New("not installed")},
commandKey("wsl.exe", "--install", "--no-distribution"): {Err: errors.New("blocked"), Stderr: "no admin"},
}}
report = NewRunReport(Options{}, []Phase{PhaseWSL})
issues = app.executeCheckedPlan(context.Background(), Plan{Phases: []Phase{PhaseWSL}}, Options{}, report)
if issues.Err() == nil || len(report.Issues) != 1 || !strings.Contains(errOut.String(), "ERROR: wsl") {
t.Fatalf("issues = %#v report = %#v stderr = %s", issues, report.Issues, errOut.String())
}
}
func TestExecuteCheckedPlanCoversOSStopAndHostConfigureIssue(t *testing.T) {
temp := t.TempDir()
update := WindowsUpdateCommand()
reboot := windowsRebootProbeCommand()
app := &Bootstrapper{
Runner: &fakeRunner{responses: map[string]CommandResult{
commandKey(update.Name, update.Args...): {},
commandKey(reboot.Name, reboot.Args...): {Stdout: "true\n"},
}, defaultErr: true},
Out: &bytes.Buffer{},
Err: &bytes.Buffer{},
Paths: testUserPaths(temp),
}
report := NewRunReport(Options{}, []Phase{PhaseOS})
issues := app.executeCheckedPlan(context.Background(), Plan{Phases: []Phase{PhaseOS}}, Options{}, report)
if issues.Err() != nil {
t.Fatal(issues.Err())
}
if len(report.Notices) != 1 || !strings.Contains(report.Events[len(report.Events)-1], "notice:") {
t.Fatalf("report = %#v, want reboot notice and early return", report)
}
app.Runner = staticRunner{result: CommandResult{Err: errors.New("detect failed"), Stderr: "blocked"}}
report = NewRunReport(Options{}, []Phase{PhaseHost})
issues = app.executeCheckedPlan(context.Background(), Plan{Phases: []Phase{PhaseHost}}, Options{}, report)
if len(issues) != 1 || issues[0].Step != "detect Hyper-V" {
t.Fatalf("issues = %#v, want Hyper-V detect issue", issues)
}
}
func TestRunConfigPhaseRecordsDeploymentIssue(t *testing.T) {
temp := t.TempDir()
homeFile := filepath.Join(temp, "home")
if err := os.WriteFile(homeFile, []byte("not a directory"), 0o644); err != nil {
t.Fatal(err)
}
app := &Bootstrapper{
Runner: &fakeRunner{},
Out: &bytes.Buffer{},
Err: &bytes.Buffer{},
Paths: UserPaths{
Home: homeFile,
LocalAppData: filepath.Join(temp, "local"),
Documents: filepath.Join(temp, "docs"),
},
}
issues := app.runConfigPhase(context.Background(), Options{Headless: true})
if len(issues) == 0 || !strings.Contains(issues[0].Step, "deploy managed config") {
t.Fatalf("issues = %#v, want deployment issue", issues)
}
}
func TestRunConfigPhaseRecordsNeovimIssue(t *testing.T) {
temp := t.TempDir()
target := filepath.Join(temp, "local", "nvim")
app := &Bootstrapper{
Runner: &fakeRunner{responses: map[string]CommandResult{
commandKey("git", "clone", "https://github.com/JMR-dev/nvim-config.git", target): {Err: errors.New("clone failed"), Stderr: "offline"},
}},
Out: &bytes.Buffer{},
Err: &bytes.Buffer{},
Paths: testUserPaths(temp),
}
issues := app.runConfigPhase(context.Background(), Options{Headless: true})
if len(issues) != 1 || issues[0].Step != "clone Neovim config" {
t.Fatalf("issues = %#v, want Neovim clone issue", issues)
}
}
func TestRunConfigPhaseOffersAuthWhenInteractive(t *testing.T) {
temp := t.TempDir()
pub := filepath.Join(temp, "home", ".ssh", "id_ed25519.pub")
if err := os.MkdirAll(filepath.Dir(pub), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(pub, []byte("pub"), 0o644); err != nil {
t.Fatal(err)
}
app := &Bootstrapper{
Runner: &fakeRunner{},
In: strings.NewReader(""),
Out: &bytes.Buffer{},
Err: &bytes.Buffer{},
Paths: testUserPaths(temp),
}
if issues := app.runConfigPhase(context.Background(), Options{}); issues.Err() != nil {
t.Fatalf("issues = %#v", issues)
}
if !app.Runner.(*fakeRunner).called("gh", "auth", "status") {
t.Fatal("expected interactive auth status check")
}
}
func TestConfigureHyperVRecordsDetectAndEnableFailures(t *testing.T) {
app := &Bootstrapper{Runner: staticRunner{result: CommandResult{Err: errors.New("probe failed"), Stderr: "blocked"}}}
if notice, issue := app.configureHyperV(context.Background()); notice != "" || issue == nil || issue.Step != "detect Hyper-V" {
t.Fatalf("notice = %q issue = %#v, want detect issue", notice, issue)
}
app.Runner = &queueRunner{results: []CommandResult{
{Stdout: "Edition=Windows 11 Pro\nSupported=True\nEnabled=False\nRestartRequired=False\n"},
{Err: errors.New("dism failed"), Stderr: "feature unavailable"},
}}
if notice, issue := app.configureHyperV(context.Background()); notice != "" || issue == nil || issue.Step != "configure Hyper-V" {
t.Fatalf("notice = %q issue = %#v, want configure issue", notice, issue)
}
app.Runner = &queueRunner{results: []CommandResult{
{Stdout: "Edition=Windows 11 Pro\nSupported=True\nEnabled=False\nRestartRequired=False\n"},
{},
}}
if notice, issue := app.configureHyperV(context.Background()); issue != nil || !strings.Contains(notice, "features were enabled") {
t.Fatalf("notice = %q issue = %#v, want enable notice", notice, issue)
}
}
func TestOfferInteractiveAuthCoversLoginFailure(t *testing.T) {
temp := t.TempDir()
host, _ := os.Hostname()
email := os.Getenv("USERNAME") + "@" + host
key := filepath.Join(temp, ".ssh", "id_ed25519")
runner := &fakeRunner{responses: map[string]CommandResult{
commandKey("gh", "auth", "status"): {Err: errors.New("not logged in")},
commandKey("gh", "auth", "login"): {Err: errors.New("login failed"), Stderr: "bad browser"},
commandKey("ssh-keygen", "-t", "ed25519", "-C", email, "-f", key, "-N", ""): {},
commandKey("gh", "ssh-key", "add", key+".pub", "--title", "bootstrap_windows_env "+host): {Err: errors.New("upload failed"), Stderr: "denied"},
}, defaultErr: true}
app := &Bootstrapper{
Runner: runner,
In: strings.NewReader("y\n"),
Out: &bytes.Buffer{},
Err: &bytes.Buffer{},
Paths: UserPaths{Home: temp},
}
issues := app.offerInteractiveAuth(context.Background())
if len(issues) != 1 || issues[0].Step != "gh auth login" {
t.Fatalf("issues = %#v, want login failure", issues)
}
}
func TestOfferInteractiveAuthCoversSSHUploadFailure(t *testing.T) {
temp := t.TempDir()
host, _ := os.Hostname()
email := os.Getenv("USERNAME") + "@" + host
key := filepath.Join(temp, ".ssh", "id_ed25519")
runner := &fakeRunner{responses: map[string]CommandResult{
commandKey("gh", "auth", "status"): {},
commandKey("ssh-keygen", "-t", "ed25519", "-C", email, "-f", key, "-N", ""): {},
commandKey("gh", "ssh-key", "add", key+".pub", "--title", "bootstrap_windows_env "+host): {Err: errors.New("upload failed"), Stderr: "denied"},
}, defaultErr: true}
app := &Bootstrapper{
Runner: runner,
In: strings.NewReader("y\n"),
Out: &bytes.Buffer{},
Err: &bytes.Buffer{},
Paths: UserPaths{Home: temp},
}
issues := app.offerInteractiveAuth(context.Background())
if len(issues) != 1 || issues[0].Step != "gh ssh-key add" {
t.Fatalf("issues = %#v, want upload failure", issues)
}
if !runner.called("ssh-keygen", "-t", "ed25519", "-C", email, "-f", key, "-N", "") {
t.Fatal("expected ssh-keygen command")
}
}
func TestOfferInteractiveAuthCoversSSHDirectoryAndKeygenFailures(t *testing.T) {
temp := t.TempDir()
homeFile := filepath.Join(temp, "home")
if err := os.WriteFile(homeFile, []byte("not a directory"), 0o644); err != nil {
t.Fatal(err)
}
app := &Bootstrapper{
Runner: &fakeRunner{responses: map[string]CommandResult{
commandKey("gh", "auth", "status"): {},
}},
In: strings.NewReader("y\n"),
Out: &bytes.Buffer{},
Err: &bytes.Buffer{},
Paths: UserPaths{Home: homeFile},
}
issues := app.offerInteractiveAuth(context.Background())
if len(issues) != 1 || issues[0].Step != "create SSH directory" {
t.Fatalf("issues = %#v, want SSH directory failure", issues)
}
host, _ := os.Hostname()
email := os.Getenv("USERNAME") + "@" + host
key := filepath.Join(temp, "home2", ".ssh", "id_ed25519")
runner := &fakeRunner{responses: map[string]CommandResult{
commandKey("gh", "auth", "status"): {},
commandKey("ssh-keygen", "-t", "ed25519", "-C", email, "-f", key, "-N", ""): {Err: errors.New("keygen failed"), Stderr: "bad key"},
}, defaultErr: true}
app = &Bootstrapper{
Runner: runner,
In: strings.NewReader("y\n"),
Out: &bytes.Buffer{},
Err: &bytes.Buffer{},
Paths: UserPaths{Home: filepath.Join(temp, "home2")},
}
issues = app.offerInteractiveAuth(context.Background())
if len(issues) != 1 || issues[0].Step != "ssh-keygen" {
t.Fatalf("issues = %#v, want ssh-keygen failure", issues)
}
if runner.called("gh", "ssh-key", "add", key+".pub", "--title", "bootstrap_windows_env "+host) {
t.Fatal("ssh key should not be uploaded after key generation failure")
}
}
func TestConfirmHandlesYesNoAndMissingInput(t *testing.T) {
if !confirm(strings.NewReader("YES\n"), &bytes.Buffer{}, "prompt") {
t.Fatal("YES should confirm")
}
if confirm(strings.NewReader("n\n"), &bytes.Buffer{}, "prompt") {
t.Fatal("n should not confirm")
}
if confirm(strings.NewReader(""), &bytes.Buffer{}, "prompt") {
t.Fatal("missing input should not confirm")
}
}
func TestRecordIssuesEmptyAndHasPhaseFalse(t *testing.T) {
app := &Bootstrapper{Err: &bytes.Buffer{}}
report := NewRunReport(Options{}, nil)
var issues Issues
app.recordIssues(report, &issues, nil)
if len(issues) != 0 || len(report.Issues) != 0 {
t.Fatalf("issues = %#v report = %#v, want no changes", issues, report.Issues)
}
if hasPhase([]Phase{PhaseHost}, PhaseOS) {
t.Fatal("hasPhase should be false for missing phase")
}
}
func testUserPaths(root string) UserPaths {
return UserPaths{
Home: filepath.Join(root, "home"),
LocalAppData: filepath.Join(root, "local"),
Documents: filepath.Join(root, "Documents"),
}
}
type queueRunner struct {
results []CommandResult
}
func (r *queueRunner) Run(ctx context.Context, name string, args ...string) CommandResult {
if len(r.results) == 0 {
return CommandResult{}
}
result := r.results[0]
r.results = r.results[1:]
return result
}