package gotool import ( "bytes" "context" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" "time" ) type goPreparationRecord struct { Arguments []string Directory string Workspace string Toolchain string Proxy string Private string NoSumDB string ModuleCache string } func TestMain(m *testing.M) { if os.Getenv("RELEASEKIT_GO_FIXTURE") == "1" { os.Exit(runGoPreparationFixture()) } os.Exit(m.Run()) } func runGoPreparationFixture() int { record := goPreparationRecord{ Arguments: os.Args[1:], Directory: mustGetwd(), Workspace: os.Getenv("GOWORK"), Toolchain: os.Getenv("GOTOOLCHAIN"), Proxy: os.Getenv("GOPROXY"), Private: os.Getenv("GOPRIVATE"), NoSumDB: os.Getenv("GONOSUMDB"), ModuleCache: os.Getenv("GOMODCACHE"), } contents, err := json.Marshal(record) if err != nil { return 10 } if err := os.WriteFile(os.Getenv("RELEASEKIT_GO_FIXTURE_RECORD"), contents, 0o600); err != nil { return 11 } switch os.Getenv("RELEASEKIT_GO_FIXTURE_MODE") { case "discovery": settings := make(map[string]string) for _, key := range os.Args[3:] { settings[key] = os.Getenv(key) } settings["GOHOSTOS"] = runtime.GOOS settings["GOHOSTARCH"] = runtime.GOARCH fmt.Fprintln(os.Stderr, "fixture discovery diagnostic") if err := json.NewEncoder(os.Stdout).Encode(settings); err != nil { return 12 } return 0 case "fail": fmt.Fprintln(os.Stderr, "fixture dependency failure") return 23 case "output": fmt.Fprintln(os.Stdout, "fixture stdout") fmt.Fprintln(os.Stderr, "fixture stderr") return 0 case "large-fail": _, _ = fmt.Fprint(os.Stdout, strings.Repeat("x", 8<<20)) fmt.Fprintln(os.Stderr, "fixture large failure") return 23 case "wait": for { time.Sleep(time.Second) } default: return 0 } } func TestDiscoveryAcceptsDiagnosticsWithValidConfiguration(t *testing.T) { repository, _ := installGoPreparationFixture(t) t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "discovery") cache := filepath.Join(t.TempDir(), "cache") t.Setenv("GOCACHE", cache) tool, err := New(context.Background(), repository) if err != nil { t.Fatalf("New() rejected successful discovery: %v", err) } if tool.HostTarget() != (Target{OS: runtime.GOOS, Arch: runtime.GOARCH}) || tool.settings["GOCACHE"] != cache { t.Fatalf("discovered host=%v cache=%q", tool.HostTarget(), tool.settings["GOCACHE"]) } } func TestDiscoveryPreservesFailureDiagnosticsAndCause(t *testing.T) { repository, _ := installGoPreparationFixture(t) t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "fail") _, err := New(context.Background(), repository) var exitError *exec.ExitError if !errors.As(err, &exitError) || exitError.ExitCode() != 23 || !strings.Contains(err.Error(), "fixture dependency failure") { t.Fatalf("New() error = %v", err) } } func mustGetwd() string { directory, err := os.Getwd() if err != nil { return "" } return directory } func installGoPreparationFixture(t *testing.T) (repository, recordPath string) { t.Helper() directory := t.TempDir() repository = t.TempDir() executable, err := os.Executable() if err != nil { t.Fatal(err) } contents, err := os.ReadFile(executable) if err != nil { t.Fatal(err) } name := "go" if runtime.GOOS == "windows" { name += ".exe" } if err := os.WriteFile(filepath.Join(directory, name), contents, 0o755); err != nil { t.Fatal(err) } recordPath = filepath.Join(t.TempDir(), "go-command.json") t.Setenv("PATH", directory+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("RELEASEKIT_GO_FIXTURE", "1") t.Setenv("RELEASEKIT_GO_FIXTURE_RECORD", recordPath) return repository, recordPath } func TestPrepareBuildDependenciesPreservesPreparationEnvironment(t *testing.T) { repository, recordPath := installGoPreparationFixture(t) moduleCache := filepath.Join(t.TempDir(), "module-cache") t.Setenv("GOWORK", filepath.Join(t.TempDir(), "go.work")) t.Setenv("GOTOOLCHAIN", "auto") t.Setenv("GOPROXY", "https://proxy.example.invalid") t.Setenv("GOPRIVATE", "example.invalid/private") t.Setenv("GONOSUMDB", "example.invalid/private") t.Setenv("GOMODCACHE", moduleCache) if err := PrepareBuildDependencies(context.Background(), repository); err != nil { t.Fatal(err) } contents, err := os.ReadFile(recordPath) if err != nil { t.Fatal(err) } var record goPreparationRecord if err := json.Unmarshal(contents, &record); err != nil { t.Fatal(err) } if strings.Join(record.Arguments, " ") != "mod download" || record.Directory != repository { t.Fatalf("Go invocation = %#v", record) } if record.Workspace != "off" || record.Toolchain != "local" { t.Fatalf("preparation Go settings = %#v", record) } if record.Proxy != "https://proxy.example.invalid" || record.Private != "example.invalid/private" || record.NoSumDB != "example.invalid/private" || record.ModuleCache != moduleCache { t.Fatalf("caller dependency settings were not preserved: %#v", record) } } func TestPrepareBuildDependenciesPropagatesCommandFailure(t *testing.T) { repository, _ := installGoPreparationFixture(t) t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "fail") err := PrepareBuildDependencies(context.Background(), repository) var exitError *exec.ExitError if !errors.As(err, &exitError) || exitError.ExitCode() != 23 || !strings.Contains(err.Error(), "fixture dependency failure") { t.Fatalf("PrepareBuildDependencies() error = %v", err) } } func TestPrepareBuildDependenciesPropagatesCancellation(t *testing.T) { repository, recordPath := installGoPreparationFixture(t) t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "wait") ctx, cancel := context.WithCancel(context.Background()) defer cancel() done := make(chan error, 1) go func() { done <- PrepareBuildDependencies(ctx, repository) }() deadline := time.Now().Add(5 * time.Second) for { if _, err := os.Stat(recordPath); err == nil { break } if time.Now().After(deadline) { t.Fatal("Go fixture did not start") } time.Sleep(10 * time.Millisecond) } cancel() select { case err := <-done: if !errors.Is(err, context.Canceled) { t.Fatalf("PrepareBuildDependencies() error = %v; want cancellation", err) } case <-time.After(5 * time.Second): t.Fatal("Go fixture did not stop after cancellation") } } func TestNativeAndStaticCommandsUseOwnedBaseline(t *testing.T) { clearGoSettings(t) t.Setenv("GOENV", "off") t.Setenv("GOFLAGS", "-run=^$") t.Setenv("GOOS", "windows") t.Setenv("GOARCH", "arm64") t.Setenv("GOAMD64", "v2") t.Setenv("GOEXPERIMENT", "loopvar") tool, err := New(context.Background(), t.TempDir()) if err != nil { t.Fatal(err) } if tool.HostTarget() != (Target{OS: runtime.GOOS, Arch: runtime.GOARCH}) { t.Fatalf("Go host = %#v, want runtime host %s/%s", tool.HostTarget(), runtime.GOOS, runtime.GOARCH) } native := readSettings(t, tool, Native, Target{}, "GOENV", "GOOS", "GOARCH", "GOFLAGS", "GOAMD64", "GOEXPERIMENT", "CGO_ENABLED") if native["GOENV"] != "" || native["GOOS"] != runtime.GOOS || native["GOARCH"] != runtime.GOARCH { t.Fatalf("native environment = %#v", native) } if native["GOFLAGS"] != "-mod=readonly" || native["GOAMD64"] == "v2" || native["GOEXPERIMENT"] != "" { t.Fatalf("native Go settings = %#v", native) } if native["CGO_ENABLED"] == "" { t.Fatalf("native cgo setting was not resolved: %#v", native) } static := readSettings(t, tool, Static, Target{OS: "linux", Arch: "amd64"}, "GOOS", "GOARCH", "GOFLAGS", "CGO_ENABLED", "GOAMD64", "GOEXPERIMENT") if static["GOOS"] != "linux" || static["GOARCH"] != "amd64" || static["CGO_ENABLED"] != "0" { t.Fatalf("static environment = %#v", static) } if static["GOFLAGS"] != "-mod=readonly" || static["GOAMD64"] != "v1" || static["GOEXPERIMENT"] != "" { t.Fatalf("static baseline settings = %#v", static) } } func TestPersistentSettingsPreserveCachesAndNativeCTools(t *testing.T) { clearGoSettings(t) cache := filepath.Join(t.TempDir(), "configured-cache") moduleCache := filepath.Join(t.TempDir(), "configured-module-cache") temporary := filepath.Join(t.TempDir(), "configured-temporary") if err := os.Mkdir(temporary, 0o700); err != nil { t.Fatal(err) } goEnv := filepath.Join(t.TempDir(), "go-env") contents := strings.Join([]string{ "GOFLAGS=-run=^$", "GOOS=windows", "GOARCH=arm64", "GOAMD64=v2", "GOEXPERIMENT=loopvar", "GOCACHE=" + cache, "GOMODCACHE=" + moduleCache, "GOTMPDIR=" + temporary, "CGO_ENABLED=1", "CC=releasekit-test-cc", "CXX=releasekit-test-cxx", "CGO_CFLAGS=-O0 -g", "PKG_CONFIG=releasekit-test-pkg-config", }, "\n") + "\n" if err := os.WriteFile(goEnv, []byte(contents), 0o600); err != nil { t.Fatal(err) } t.Setenv("GOENV", goEnv) tool, err := New(context.Background(), t.TempDir()) if err != nil { t.Fatal(err) } if tool.HostTarget() != (Target{OS: runtime.GOOS, Arch: runtime.GOARCH}) { t.Fatalf("Go host = %#v, want runtime host %s/%s", tool.HostTarget(), runtime.GOOS, runtime.GOARCH) } native := readSettings(t, tool, Native, Target{}, "GOENV", "GOOS", "GOARCH", "GOFLAGS", "GOAMD64", "GOEXPERIMENT", "CGO_ENABLED", "CC", "CXX", "CGO_CFLAGS", "GOCACHE", "GOMODCACHE", "GOTMPDIR", "PKG_CONFIG") if native["GOENV"] != "" || native["GOOS"] != runtime.GOOS || native["GOARCH"] != runtime.GOARCH { t.Fatalf("native environment = %#v", native) } if native["GOFLAGS"] != "-mod=readonly" || native["GOAMD64"] == "v2" || native["GOEXPERIMENT"] != "" { t.Fatalf("persisted overrides were not cleared: %#v", native) } if native["CGO_ENABLED"] != "1" || native["CC"] != "releasekit-test-cc" || native["CXX"] != "releasekit-test-cxx" || native["CGO_CFLAGS"] != "-O0 -g" || native["PKG_CONFIG"] != "releasekit-test-pkg-config" { t.Fatalf("persistent native C settings = %#v", native) } if native["GOCACHE"] != cache || native["GOMODCACHE"] != moduleCache || native["GOTMPDIR"] != temporary { t.Fatalf("persistent cache settings = %#v", native) } static := readSettings(t, tool, Static, Target{OS: "linux", Arch: "amd64"}, "GOENV", "GOOS", "GOARCH", "GOFLAGS", "CGO_ENABLED", "GOAMD64", "GOEXPERIMENT", "GOCACHE") if static["GOENV"] != "" || static["GOOS"] != "linux" || static["GOARCH"] != "amd64" || static["CGO_ENABLED"] != "0" || static["GOAMD64"] != "v1" || static["GOEXPERIMENT"] != "" || static["GOCACHE"] != cache { t.Fatalf("persistent static environment = %#v", static) } } func TestOwnerReusesSettingsUntilInheritedEnvironmentChanges(t *testing.T) { clearGoSettings(t) t.Setenv("GOENV", "off") owner := NewOwner(t.TempDir()) first, err := owner.Acquire(context.Background()) if err != nil { t.Fatal(err) } second, err := owner.Acquire(context.Background()) if err != nil { t.Fatal(err) } if first != second { t.Fatal("unchanged Go inputs caused settings discovery to be repeated") } cache := filepath.Join(t.TempDir(), "changed-cache") t.Setenv("GOCACHE", cache) if err := owner.Refresh(context.Background()); err != nil { t.Fatal(err) } third, err := owner.Acquire(context.Background()) if err != nil { t.Fatal(err) } if third == first || third.settings["GOCACHE"] != cache { t.Fatalf("changed inherited environment kept stale settings: tool=%p GOCACHE=%q", third, third.settings["GOCACHE"]) } } func TestOwnerRefreshesWhenPersistentSettingsChange(t *testing.T) { clearGoSettings(t) firstCache := filepath.Join(t.TempDir(), "first-cache") secondCache := filepath.Join(t.TempDir(), "second-cache") settingsFile := filepath.Join(t.TempDir(), "go-env") writeSettings := func(cache string) { t.Helper() if err := os.WriteFile(settingsFile, []byte("GOCACHE="+cache+"\n"), 0o600); err != nil { t.Fatal(err) } } writeSettings(firstCache) t.Setenv("GOENV", settingsFile) owner := NewOwner(t.TempDir()) first, err := owner.Acquire(context.Background()) if err != nil { t.Fatal(err) } if first.settings["GOCACHE"] != firstCache { t.Fatalf("initial GOCACHE = %q, want %q", first.settings["GOCACHE"], firstCache) } writeSettings(secondCache) if err := owner.Refresh(context.Background()); err != nil { t.Fatal(err) } second, err := owner.Acquire(context.Background()) if err != nil { t.Fatal(err) } if second == first || second.settings["GOCACHE"] != secondCache { t.Fatalf("changed persistent settings were not rediscovered: tool=%p GOCACHE=%q", second, second.settings["GOCACHE"]) } } func readSettings(t *testing.T, tool *Tool, mode Mode, target Target, names ...string) map[string]string { t.Helper() args := append([]string{"env", "-json"}, names...) output, err := tool.Output(context.Background(), t.TempDir(), mode, target, nil, args...) if err != nil { t.Fatal(err) } var settings map[string]string if err := json.Unmarshal(output, &settings); err != nil { t.Fatalf("decode Go environment %q: %v", output, err) } return settings } func TestOutputReturnsAndForwardsCombinedOutput(t *testing.T) { repository, _ := installGoPreparationFixture(t) t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "output") tool := fixtureTool() var forwarded bytes.Buffer output, err := tool.Output(context.Background(), repository, Native, Target{}, &forwarded, "version") if err != nil { t.Fatalf("Output() error = %v", err) } if !bytes.Contains(output, []byte("fixture stdout")) || !bytes.Contains(output, []byte("fixture stderr")) { t.Fatalf("combined output = %q", output) } if !bytes.Equal(forwarded.Bytes(), output) { t.Fatalf("forwarded output differs from capture: forwarded %d bytes, captured %d", forwarded.Len(), len(output)) } } func TestRunForwardsOutputWithoutReturningCapture(t *testing.T) { repository, _ := installGoPreparationFixture(t) t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "output") tool := fixtureTool() var forwarded bytes.Buffer if err := tool.Run(context.Background(), repository, Native, Target{}, &forwarded, "version"); err != nil { t.Fatalf("Run() error = %v", err) } if !bytes.Contains(forwarded.Bytes(), []byte("fixture stdout")) || !bytes.Contains(forwarded.Bytes(), []byte("fixture stderr")) { t.Fatalf("forwarded output = %q", forwarded.String()) } } func TestRunPreservesFailureDiagnosticsAndCause(t *testing.T) { repository, _ := installGoPreparationFixture(t) t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "fail") tool := fixtureTool() err := tool.Run(context.Background(), repository, Native, Target{}, nil, "version") var exitError *exec.ExitError if !errors.As(err, &exitError) || exitError.ExitCode() != 23 || !strings.Contains(err.Error(), "fixture dependency failure") { t.Fatalf("Run() error = %v", err) } } func TestOutputReturnsLargeFailureDiagnosticsAndCause(t *testing.T) { repository, _ := installGoPreparationFixture(t) t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "large-fail") tool := fixtureTool() output, err := tool.Output(context.Background(), repository, Native, Target{}, nil, "version") var exitError *exec.ExitError wantSize := (8 << 20) + len("fixture large failure\n") if len(output) != wantSize { t.Fatalf("captured output = %d bytes, want %d", len(output), wantSize) } if !errors.As(err, &exitError) || exitError.ExitCode() != 23 || !strings.Contains(err.Error(), "fixture large failure") { t.Fatalf("Output() error = %v", err) } } func TestOutputReturnsCancellation(t *testing.T) { repository, recordPath := installGoPreparationFixture(t) t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "wait") tool := fixtureTool() ctx, cancel := context.WithCancel(context.Background()) defer cancel() done := make(chan error, 1) go func() { _, err := tool.Output(ctx, repository, Native, Target{}, nil, "version") done <- err }() deadline := time.Now().Add(5 * time.Second) for { if _, err := os.Stat(recordPath); err == nil { break } if time.Now().After(deadline) { t.Fatal("Go fixture did not start") } time.Sleep(10 * time.Millisecond) } cancel() select { case err := <-done: if !errors.Is(err, context.Canceled) { t.Fatalf("Output() error = %v; want cancellation", err) } case <-time.After(5 * time.Second): t.Fatal("Go fixture did not stop after cancellation") } } func TestOutputPreservesForwardingWriterFailure(t *testing.T) { repository, _ := installGoPreparationFixture(t) t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "output") tool := fixtureTool() writerErr := errors.New("fixture output writer failed") output, err := tool.Output(context.Background(), repository, Native, Target{}, failingOutputWriter{err: writerErr}, "version") if !errors.Is(err, writerErr) || !strings.Contains(err.Error(), "go version") { t.Fatalf("Output() error = %v; want wrapped writer error and command", err) } if len(output) == 0 { t.Fatal("Output() returned no captured bytes after forwarding failed") } } type failingOutputWriter struct{ err error } func (writer failingOutputWriter) Write([]byte) (int, error) { return 0, writer.err } func fixtureTool() *Tool { return &Tool{host: Target{OS: runtime.GOOS, Arch: runtime.GOARCH}, settings: map[string]string{}} } func clearGoSettings(t *testing.T) { t.Helper() for _, key := range []string{ "GOENV", "GOFLAGS", "GOWORK", "GOTOOLCHAIN", "GOPROXY", "GONOPROXY", "GOSUMDB", "GOOS", "GOARCH", "GOEXPERIMENT", "GOAMD64", "GOARM", "GOARM64", "GO386", "GOMIPS", "GOMIPS64", "GOPPC64", "GORISCV64", "GOWASM", "CGO_ENABLED", "CC", "CXX", "CGO_CFLAGS", "CGO_CPPFLAGS", "CGO_CXXFLAGS", "CGO_LDFLAGS", "GOCACHE", "GOCACHEPROG", "GOMODCACHE", "GOPATH", "GOROOT", "GOTMPDIR", "PKG_CONFIG", } { value, exists := os.LookupEnv(key) if err := os.Unsetenv(key); err != nil { t.Fatal(err) } t.Cleanup(func() { if exists { _ = os.Setenv(key, value) } else { _ = os.Unsetenv(key) } }) } }