474 lines
16 KiB
Go
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
|
|
}
|