diff --git a/pkg/runtime/golang/build_test.go b/pkg/runtime/golang/build_test.go new file mode 100644 index 0000000000..b41cfa5a44 --- /dev/null +++ b/pkg/runtime/golang/build_test.go @@ -0,0 +1,211 @@ +package golang + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// testIntegrationEnv returns a minimal, hermetic env for `go list`. +// GOTOOLCHAIN=local prevents network downloads of mismatched +// toolchains, GOPROXY=off prevents module fetches, GOMODCACHE/GOCACHE +// are redirected so the host caches stay untouched. GOWORK=off keeps +// the test isolated from any go.work file the host may have. +func testIntegrationEnv(t *testing.T) []string { + t.Helper() + return []string{ + "PATH=" + os.Getenv("PATH"), + "HOME=" + t.TempDir(), + "GOMODCACHE=" + filepath.Join(t.TempDir(), "modcache"), + "GOCACHE=" + filepath.Join(t.TempDir(), "gocache"), + "GOPROXY=off", + "GOFLAGS=-mod=mod", + "GOTOOLCHAIN=local", + "GOWORK=off", + "GOSUMDB=off", + } +} + +// requireGoToolchain skips when `go test -short` is set or when the +// `go` binary is not in PATH. +func requireGoToolchain(t *testing.T) { + t.Helper() + if testing.Short() { + t.Skip("integration test; skipped under -short") + } + if _, err := exec.LookPath("go"); err != nil { + t.Skipf("go toolchain not available: %v", err) + } +} + +func TestCaptureDeps_SimpleMain(t *testing.T) { + requireGoToolchain(t) + + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "go.mod"), "module example.test\n\ngo 1.22\n") + main := filepath.Join(dir, "main.go") + mustWriteFile(t, main, "package main\n\nfunc main() {}\n") + + r := New() + deps, err := r.captureDeps(context.Background(), dir, ".", testIntegrationEnv(t)) + if err != nil { + t.Fatalf("captureDeps: %v", err) + } + files := deps.files + + if _, ok := files[main]; !ok { + t.Errorf("expected main.go (%s) in captured files, got %v", main, keys(files)) + } + for f := range files { + if !strings.HasPrefix(f, dir) { + t.Errorf("captured file outside module root: %s (root=%s)", f, dir) + } + } +} + +func TestCaptureDeps_LocalDependency(t *testing.T) { + requireGoToolchain(t) + + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "go.mod"), "module example.test\n\ngo 1.22\n") + + mustMkdirAll(t, filepath.Join(dir, "shared")) + sharedFile := filepath.Join(dir, "shared", "lib.go") + mustWriteFile(t, sharedFile, "package shared\n\nfunc Hello() string { return \"hi\" }\n") + + mainFile := filepath.Join(dir, "main.go") + mustWriteFile(t, mainFile, + "package main\n\nimport \"example.test/shared\"\n\nfunc main() { _ = shared.Hello() }\n") + + r := New() + deps, err := r.captureDeps(context.Background(), dir, ".", testIntegrationEnv(t)) + if err != nil { + t.Fatalf("captureDeps: %v", err) + } + files := deps.files + + if _, ok := files[mainFile]; !ok { + t.Errorf("expected main.go (%s) in captured files, got %v", mainFile, keys(files)) + } + if _, ok := files[sharedFile]; !ok { + t.Errorf("expected shared/lib.go (%s) in captured files, got %v", sharedFile, keys(files)) + } +} + +func TestCaptureDeps_NoFilesOutsideModuleRoot(t *testing.T) { + requireGoToolchain(t) + + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "go.mod"), "module example.test\n\ngo 1.22\n") + mustWriteFile(t, filepath.Join(dir, "main.go"), + "package main\n\nimport \"fmt\"\n\nfunc main() { fmt.Println(\"hi\") }\n") + + r := New() + deps, err := r.captureDeps(context.Background(), dir, ".", testIntegrationEnv(t)) + if err != nil { + t.Fatalf("captureDeps: %v", err) + } + files := deps.files + + for f := range files { + if !strings.HasPrefix(f, dir) { + t.Errorf("file outside module root leaked into captured set: %s", f) + } + } +} + +func TestCaptureDeps_NoTestFilesIncluded(t *testing.T) { + requireGoToolchain(t) + + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "go.mod"), "module example.test\n\ngo 1.22\n") + mustWriteFile(t, filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n") + testFile := filepath.Join(dir, "main_test.go") + mustWriteFile(t, testFile, "package main\n\nimport \"testing\"\n\nfunc TestX(t *testing.T) {}\n") + + r := New() + deps, err := r.captureDeps(context.Background(), dir, ".", testIntegrationEnv(t)) + if err != nil { + t.Fatalf("captureDeps: %v", err) + } + files := deps.files + + if _, ok := files[testFile]; ok { + t.Errorf("test file %s leaked into captured set", testFile) + } +} + +func TestCaptureDeps_BrokenSourceFails(t *testing.T) { + requireGoToolchain(t) + + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "go.mod"), "module example.test\n\ngo 1.22\n") + mustWriteFile(t, filepath.Join(dir, "main.go"), "this is not valid go source") + + r := New() + if _, err := r.captureDeps(context.Background(), dir, ".", testIntegrationEnv(t)); err == nil { + t.Error("expected error on unparseable source, got nil") + } +} + +func TestCaptureDeps_SubPackageHandler(t *testing.T) { + requireGoToolchain(t) + + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "go.mod"), "module example.test\n\ngo 1.22\n") + + mustMkdirAll(t, filepath.Join(dir, "shared")) + sharedFile := filepath.Join(dir, "shared", "lib.go") + mustWriteFile(t, sharedFile, "package shared\n\nfunc Hello() string { return \"hi\" }\n") + + mustMkdirAll(t, filepath.Join(dir, "lambdas", "alpha")) + alphaFile := filepath.Join(dir, "lambdas", "alpha", "main.go") + mustWriteFile(t, alphaFile, + "package main\n\nimport \"example.test/shared\"\n\nfunc main() { _ = shared.Hello() }\n") + + mustMkdirAll(t, filepath.Join(dir, "lambdas", "beta")) + betaFile := filepath.Join(dir, "lambdas", "beta", "main.go") + mustWriteFile(t, betaFile, "package main\n\nfunc main() {}\n") + + r := New() + deps, err := r.captureDeps(context.Background(), dir, "lambdas/alpha", testIntegrationEnv(t)) + if err != nil { + t.Fatalf("captureDeps: %v", err) + } + files := deps.files + + if _, ok := files[alphaFile]; !ok { + t.Errorf("expected alpha main.go in captured files, got %v", keys(files)) + } + if _, ok := files[sharedFile]; !ok { + t.Errorf("expected shared/lib.go in captured files, got %v", keys(files)) + } + if _, ok := files[betaFile]; ok { + t.Errorf("beta sibling main.go must NOT be in alpha's import graph: %s", betaFile) + } +} + +func keys(m map[string]struct{}) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +func mustMkdirAll(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } +} + +func mustWriteFile(t *testing.T, path string, contents string) { + t.Helper() + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/runtime/golang/golang.go b/pkg/runtime/golang/golang.go index 6059faef34..651d41210b 100644 --- a/pkg/runtime/golang/golang.go +++ b/pkg/runtime/golang/golang.go @@ -16,21 +16,37 @@ import ( "github.com/sst/sst/v3/pkg/runtime" ) +// Runtime implements [runtime.Runtime] for Go-based Lambda functions. +// It builds each handler with `go build` and tracks the per-handler +// import graph so [Runtime.ShouldRebuild] only fires for files that +// actually compile into the handler's binary. type Runtime struct { - mut sync.Mutex - directories map[string]string + mut sync.RWMutex + files map[string]map[string]struct{} + pkgDirs map[string]map[string]struct{} + gomodPaths map[string]string + gomodcacheMut sync.Mutex + gomodcache string + gomodcacheResolved bool + // gomodcacheOverride is a test seam set at construction time and + // never mutated afterwards, so it can be read without the mutex. + gomodcacheOverride string } +// Worker is a running Lambda handler binary started by [Runtime.Run]. type Worker struct { stdout io.ReadCloser stderr io.ReadCloser cmd *exec.Cmd } +// Stop terminates the running worker process. func (w *Worker) Stop() { process.Kill(w.cmd.Process) } +// Logs returns a single reader that streams the worker's stdout and +// stderr interleaved. func (w *Worker) Logs() io.ReadCloser { reader, writer := io.Pipe() @@ -53,23 +69,40 @@ func (w *Worker) Logs() io.ReadCloser { return reader } +// New returns a Runtime with empty per-function state. func New() *Runtime { return &Runtime{ - directories: map[string]string{}, + files: map[string]map[string]struct{}{}, + pkgDirs: map[string]map[string]struct{}{}, + gomodPaths: map[string]string{}, } } +// Match reports whether this Runtime handles functions declared with +// `runtime: "go"`. func (r *Runtime) Match(runtime string) bool { return runtime == "go" } +// Properties is the runtime-specific configuration block in the SST +// component output for a Go function. type Properties struct { Architecture string `json:"architecture"` } +func goarchFromArchitecture(arch string) string { + if arch == "arm64" { + return "arm64" + } + return "amd64" +} + +// Build compiles the handler with `go build` and captures its +// transitive import graph for use by ShouldRebuild. A failed compile is +// returned as a non-nil BuildOutput with errors; a failed graph capture +// is logged and disables ShouldRebuild for that function until the +// next successful build. func (r *Runtime) Build(ctx context.Context, input *runtime.BuildInput) (*runtime.BuildOutput, error) { - r.mut.Lock() - defer r.mut.Unlock() var properties Properties json.Unmarshal(input.Properties, &properties) @@ -77,7 +110,6 @@ func (r *Runtime) Build(ctx context.Context, input *runtime.BuildInput) (*runtim if err != nil { return nil, err } - // root of go project root := filepath.Dir(gomod) src, _ := filepath.Rel(root, input.Handler) out := filepath.Join(input.Out(), "bootstrap") @@ -87,23 +119,49 @@ func (r *Runtime) Build(ctx context.Context, input *runtime.BuildInput) (*runtim args = append(args, "-ldflags", "-s -w") env = append(env, "CGO_ENABLED=0") env = append(env, "GOOS=linux") - env = append(env, "GOARCH=amd64") - if properties.Architecture == "arm64" { - env = append(env, "GOARCH=arm64") - } + env = append(env, "GOARCH="+goarchFromArchitecture(properties.Architecture)) } - args = append(args, "-o", out, src) + // "./" prefix forces relative path resolution; without it `go build` + // treats a sub-path like "commands/connect" as a stdlib package name. + args = append(args, "-o", out, "./"+src) cmd := process.Command("go", args...) cmd.Dir = root cmd.Env = env - slog.Info("running go build", "cmd", cmd.Args) + slog.Debug("running go build", "cmd", cmd.Args) output, err := cmd.CombinedOutput() if err != nil { return &runtime.BuildOutput{ Errors: []string{string(output)}, }, nil } - r.directories[input.FunctionID], _ = filepath.Abs(root) + + absRoot, _ := filepath.Abs(root) + absGomod, _ := filepath.Abs(gomod) + deps, depErr := r.captureDeps(ctx, absRoot, src, env) + + r.mut.Lock() + r.gomodPaths[input.FunctionID] = absGomod + // On capture failure keep the previous graph (if any): the next file + // edit on a still-tracked file should still rebuild correctly. A + // successful build below replaces it; until then we degrade + // gracefully rather than disabling rebuild detection entirely. + if depErr == nil { + r.files[input.FunctionID] = deps.files + r.pkgDirs[input.FunctionID] = deps.pkgDirs + } + r.mut.Unlock() + + if depErr != nil { + slog.Warn("failed to capture go deps; keeping previous graph for rebuild detection", + "fn", input.FunctionID, + "err", depErr, + "root", absRoot, + "src", src, + "goflags", os.Getenv("GOFLAGS"), + "gomodcache", r.resolveGoModCache(env), + ) + } + return &runtime.BuildOutput{ Handler: "bootstrap", Sourcemaps: []string{}, @@ -112,6 +170,7 @@ func (r *Runtime) Build(ctx context.Context, input *runtime.BuildInput) (*runtim }, nil } +// Run starts the previously-built handler binary as a worker. func (r *Runtime) Run(ctx context.Context, input *runtime.RunInput) (runtime.Worker, error) { cmd := process.Command( filepath.Join(input.Build.Out, input.Build.Handler), @@ -130,18 +189,138 @@ func (r *Runtime) Run(ctx context.Context, input *runtime.RunInput) (runtime.Wor }, nil } +// ShouldRebuild reports whether a file change must trigger a rebuild +// of the given Lambda. Fires when the file is in the handler's +// captured import graph (covers .go, cgo and //go:embed assets), when +// it is the handler's go.mod/go.sum (those shift the resolved +// dependency set), or when it is a new .go file inside a directory +// that already belongs to the captured graph (e.g. a util.go just +// added to a tracked package). func (r *Runtime) ShouldRebuild(functionID string, file string) bool { - if !strings.HasSuffix(file, ".go") { + r.mut.RLock() + files, ok := r.files[functionID] + pkgDirs := r.pkgDirs[functionID] + gomod := r.gomodPaths[functionID] + r.mut.RUnlock() + if !ok { return false } - match, ok := r.directories[functionID] - if !ok { + abs, err := filepath.Abs(file) + if err != nil { return false } - slog.Info("checking if file needs to be rebuilt", "file", file, "match", match) - rel, err := filepath.Rel(match, file) + if _, found := files[abs]; found { + slog.Info("rebuilding go function", "fn", functionID, "file", abs, "reason", "file_in_graph") + return true + } + if gomod != "" { + gomodDir := filepath.Dir(gomod) + if abs == gomod || abs == filepath.Join(gomodDir, "go.sum") { + slog.Info("rebuilding go function", "fn", functionID, "file", abs, "reason", "gomod_change") + return true + } + } + if strings.HasSuffix(abs, ".go") { + for dir := range pkgDirs { + if isUnderDir(abs, dir) { + slog.Info("rebuilding go function", "fn", functionID, "file", abs, "reason", "new_file_in_pkg") + return true + } + } + } + return false +} + +// capturedDeps holds the result of a single captureDeps run: the set +// of source files that compile into the handler's binary, plus the +// set of package directories those files live in (used to detect new +// files added to an already-tracked package between Builds). +type capturedDeps struct { + files map[string]struct{} + pkgDirs map[string]struct{} +} + +func (r *Runtime) captureDeps(ctx context.Context, root, src string, env []string) (capturedDeps, error) { + cmd := exec.CommandContext(ctx, "go", "list", "-deps", "-json", "./"+src) + cmd.Dir = root + cmd.Env = env + out, err := cmd.Output() + if err != nil { + return capturedDeps{}, err + } + return parseGoListOutput(strings.NewReader(string(out)), r.resolveGoModCache(env)) +} + +func parseGoListOutput(r io.Reader, gomodcache string) (capturedDeps, error) { + type pkgInfo struct { + Standard bool + Goroot bool + Dir string + GoFiles []string + CgoFiles []string + EmbedFiles []string + } + + deps := capturedDeps{ + files: make(map[string]struct{}), + pkgDirs: make(map[string]struct{}), + } + dec := json.NewDecoder(r) + for { + var p pkgInfo + if err := dec.Decode(&p); err == io.EOF { + break + } else if err != nil { + return capturedDeps{}, err + } + if p.Standard || p.Goroot { + continue + } + if gomodcache != "" && isUnderDir(p.Dir, gomodcache) { + continue + } + deps.pkgDirs[p.Dir] = struct{}{} + all := make([]string, 0, len(p.GoFiles)+len(p.CgoFiles)+len(p.EmbedFiles)) + all = append(all, p.GoFiles...) + all = append(all, p.CgoFiles...) + all = append(all, p.EmbedFiles...) + for _, name := range all { + deps.files[filepath.Join(p.Dir, name)] = struct{}{} + } + } + return deps, nil +} + +func (r *Runtime) resolveGoModCache(env []string) string { + if r.gomodcacheOverride != "" { + return r.gomodcacheOverride + } + r.gomodcacheMut.Lock() + defer r.gomodcacheMut.Unlock() + if r.gomodcacheResolved { + return r.gomodcache + } + cmd := exec.Command("go", "env", "GOMODCACHE") + cmd.Env = env + out, err := cmd.Output() + if err != nil { + slog.Warn("failed to resolve GOMODCACHE; will retry on next build", "err", err) + return "" + } + r.gomodcache = strings.TrimSpace(string(out)) + r.gomodcacheResolved = true + return r.gomodcache +} + +func isUnderDir(path, dir string) bool { + cleanPath := filepath.Clean(path) + cleanDir := filepath.Clean(dir) + if cleanPath == cleanDir { + return true + } + rel, err := filepath.Rel(cleanDir, cleanPath) if err != nil { return false } - return !strings.HasPrefix(rel, "..") + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } diff --git a/pkg/runtime/golang/golang_test.go b/pkg/runtime/golang/golang_test.go new file mode 100644 index 0000000000..a630c0ea8a --- /dev/null +++ b/pkg/runtime/golang/golang_test.go @@ -0,0 +1,367 @@ +package golang + +import ( + "encoding/json" + "path/filepath" + "strings" + "sync" + "testing" +) + +func absPath(parts ...string) string { + return string(filepath.Separator) + filepath.Join(parts...) +} + +func TestProperties_JSONFieldName(t *testing.T) { + tests := []struct { + name string + payload string + want string + }{ + {"arm64", `{"architecture": "arm64"}`, "arm64"}, + {"x86_64", `{"architecture": "x86_64"}`, "x86_64"}, + {"empty", `{}`, ""}, + {"extra fields ignored", `{"architecture": "arm64", "memory": 512}`, "arm64"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var got Properties + if err := json.Unmarshal([]byte(tt.payload), &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if got.Architecture != tt.want { + t.Errorf("Architecture = %q, want %q", got.Architecture, tt.want) + } + }) + } +} + +func TestGoarchFromArchitecture(t *testing.T) { + tests := []struct { + arch string + want string + }{ + {"arm64", "arm64"}, + {"x86_64", "amd64"}, + {"", "amd64"}, + {"unknown-arch", "amd64"}, + } + for _, tt := range tests { + t.Run(tt.arch, func(t *testing.T) { + t.Parallel() + if got := goarchFromArchitecture(tt.arch); got != tt.want { + t.Errorf("goarchFromArchitecture(%q) = %q, want %q", tt.arch, got, tt.want) + } + }) + } +} + +func TestShouldRebuild_UntrackedFiles(t *testing.T) { + t.Parallel() + r := New() + r.files["fn"] = map[string]struct{}{absPath("abs", "path", "handler.go"): {}} + r.gomodPaths["fn"] = absPath("abs", "path", "go.mod") + + // Files that are not in the captured graph and are not the + // handler's go.mod/go.sum must not trigger a rebuild. + tests := []struct { + name string + file string + }{ + {"unrelated txt", absPath("abs", "path", "notes.txt")}, + {"unrelated md", absPath("abs", "path", "README.md")}, + {"editor backup", absPath("abs", "path", "handler.go.bak")}, + {"no extension", absPath("abs", "path", "handler")}, + // HasSuffix is case-sensitive — ".GO" is not ".go", but the + // graph lookup is exact, so it doesn't match anyway. + {"uppercase extension", absPath("abs", "path", "handler.GO")}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if r.ShouldRebuild("fn", tt.file) { + t.Errorf("expected false for untracked file %q", tt.file) + } + }) + } +} + +func TestShouldRebuild_EmbedAssetInGraph(t *testing.T) { + t.Parallel() + r := New() + dir := t.TempDir() + asset := filepath.Join(dir, "template.html") + r.files["fn"] = map[string]struct{}{asset: {}} + + // //go:embed assets are captured by parseGoListOutput as part of + // the file set even though they are not .go sources. Editing them + // must trigger a rebuild because they compile into the binary. + if !r.ShouldRebuild("fn", asset) { + t.Errorf("expected true for tracked embed asset: %s", asset) + } +} + +func TestShouldRebuild_GoModAndGoSum(t *testing.T) { + t.Parallel() + r := New() + dir := t.TempDir() + gomod := filepath.Join(dir, "go.mod") + gosum := filepath.Join(dir, "go.sum") + mustWriteFile(t, gomod, "module example.test\n\ngo 1.22\n") + + r.files["fn"] = map[string]struct{}{filepath.Join(dir, "main.go"): {}} + r.gomodPaths["fn"] = gomod + + // go.mod and go.sum changes shift the resolved dependency set, so + // they must trigger a rebuild even though they are not in the file + // graph (which only contains .go/cgo/embed sources). + if !r.ShouldRebuild("fn", gomod) { + t.Errorf("expected true for handler's go.mod: %s", gomod) + } + if !r.ShouldRebuild("fn", gosum) { + t.Errorf("expected true for handler's go.sum: %s", gosum) + } +} + +func TestShouldRebuild_OtherHandlersGoModIgnored(t *testing.T) { + t.Parallel() + r := New() + dirA := t.TempDir() + dirB := t.TempDir() + r.files["a"] = map[string]struct{}{filepath.Join(dirA, "main.go"): {}} + r.gomodPaths["a"] = filepath.Join(dirA, "go.mod") + r.files["b"] = map[string]struct{}{filepath.Join(dirB, "main.go"): {}} + r.gomodPaths["b"] = filepath.Join(dirB, "go.mod") + + // Editing handler B's go.mod must not rebuild handler A — they + // live in separate modules. + if r.ShouldRebuild("a", filepath.Join(dirB, "go.mod")) { + t.Errorf("expected false: handler A should ignore handler B's go.mod") + } +} + +func TestShouldRebuild_NewFileInTrackedPackage(t *testing.T) { + t.Parallel() + r := New() + pkgDir := t.TempDir() + existing := filepath.Join(pkgDir, "existing.go") + added := filepath.Join(pkgDir, "added.go") + + r.files["fn"] = map[string]struct{}{existing: {}} + r.pkgDirs["fn"] = map[string]struct{}{pkgDir: {}} + + // Editing a brand-new .go file inside a tracked package directory + // must trigger a rebuild even though the file path itself is not + // in the captured set yet — the next Build re-captures the graph. + if !r.ShouldRebuild("fn", added) { + t.Errorf("expected true for new .go file in tracked package dir: %s", added) + } +} + +func TestShouldRebuild_NewFileInUntrackedDir(t *testing.T) { + t.Parallel() + r := New() + tracked := t.TempDir() + other := t.TempDir() + r.files["fn"] = map[string]struct{}{filepath.Join(tracked, "main.go"): {}} + r.pkgDirs["fn"] = map[string]struct{}{tracked: {}} + + // A .go file in a directory the handler doesn't transitively + // import must not trigger a rebuild. + if r.ShouldRebuild("fn", filepath.Join(other, "stranger.go")) { + t.Errorf("expected false for .go file in untracked directory") + } +} + +func TestShouldRebuild_NonGoFileInTrackedDirIgnored(t *testing.T) { + t.Parallel() + r := New() + pkgDir := t.TempDir() + r.files["fn"] = map[string]struct{}{filepath.Join(pkgDir, "main.go"): {}} + r.pkgDirs["fn"] = map[string]struct{}{pkgDir: {}} + + // The "new file in tracked dir" fallback only applies to .go + // files; an untracked non-go file inside a tracked package + // (editor swap, log, build artifact) must not trigger a rebuild. + if r.ShouldRebuild("fn", filepath.Join(pkgDir, "scratch.tmp")) { + t.Errorf("expected false for non-.go file in tracked dir") + } +} + +func TestShouldRebuild_NoCapturedGraph(t *testing.T) { + t.Parallel() + r := New() + if r.ShouldRebuild("fn", absPath("abs", "path", "handler.go")) { + t.Error("expected false when no graph has been captured") + } +} + +func TestShouldRebuild_FileInGraph(t *testing.T) { + t.Parallel() + r := New() + dir := t.TempDir() + handler := filepath.Join(dir, "handler.go") + r.files["fn"] = map[string]struct{}{handler: {}} + + if !r.ShouldRebuild("fn", handler) { + t.Errorf("expected true for file in graph: %s", handler) + } +} + +func TestShouldRebuild_FileNotInGraph(t *testing.T) { + t.Parallel() + r := New() + dir := t.TempDir() + handler := filepath.Join(dir, "handler.go") + other := filepath.Join(dir, "other.go") + r.files["fn"] = map[string]struct{}{handler: {}} + + if r.ShouldRebuild("fn", other) { + t.Errorf("expected false for file not in graph: %s", other) + } +} + +func TestShouldRebuild_RelativePathIsResolved(t *testing.T) { + r := New() + dir := t.TempDir() + handler := filepath.Join(dir, "handler.go") + mustWriteFile(t, handler, "package main\n") + r.files["fn"] = map[string]struct{}{handler: {}} + + t.Chdir(dir) + if !r.ShouldRebuild("fn", "handler.go") { + t.Errorf("expected true for relative path \"handler.go\" with cwd=%s", dir) + } +} + +func TestRuntime_ConcurrentShouldRebuild(t *testing.T) { + t.Parallel() + r := New() + dir := t.TempDir() + handler := filepath.Join(dir, "handler.go") + r.files["fn"] = map[string]struct{}{handler: {}} + + const goroutines = 50 + var wg sync.WaitGroup + + wg.Add(goroutines) + for range goroutines { + go func() { + defer wg.Done() + for range 100 { + r.ShouldRebuild("fn", handler) + } + }() + } + + wg.Add(goroutines) + for i := range goroutines { + go func(i int) { + defer wg.Done() + fn := "fn" + string(rune('A'+i%26)) + for range 50 { + r.mut.Lock() + r.files[fn] = map[string]struct{}{handler: {}} + r.mut.Unlock() + } + }(i) + } + + // Writer that hits the same key the readers query. + wg.Add(1) + go func() { + defer wg.Done() + for range 100 { + r.mut.Lock() + r.files["fn"] = map[string]struct{}{handler: {}} + r.mut.Unlock() + } + }() + + wg.Wait() +} + +func TestIsUnderDir(t *testing.T) { + t.Parallel() + tests := []struct { + name string + path string + dir string + want bool + }{ + {"descendant", absPath("a", "b", "c"), absPath("a"), true}, + {"direct child", absPath("a", "b", "c"), absPath("a", "b"), true}, + {"identity", absPath("a", "b", "c"), absPath("a", "b", "c"), true}, + {"deeper than dir", absPath("a", "b", "c"), absPath("a", "b", "c", "d"), false}, + {"unrelated", absPath("a", "b", "c"), absPath("x"), false}, + {"prefix-only must not match", absPath("a", "b", "c"), absPath("a", "b", "cd"), false}, + {"trailing separator", absPath("a", "b", "c") + string(filepath.Separator), absPath("a", "b"), true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := isUnderDir(tt.path, tt.dir); got != tt.want { + t.Errorf("isUnderDir(%q, %q) = %v, want %v", tt.path, tt.dir, got, tt.want) + } + }) + } +} + +func TestParseGoListOutput_FiltersStdlibAndGOMODCACHE(t *testing.T) { + t.Parallel() + gomodcache := absPath("home", "user", "go", "pkg", "mod") + localDir := absPath("repo", "services") + stdlibDir := absPath("usr", "local", "go", "src", "fmt") + modcacheDir := filepath.Join(gomodcache, "github.com", "foo", "bar@v1.0.0") + siblingDir := absPath("workspace", "shared-replace") + + stream := strings.Join([]string{ + `{"Standard": false, "Goroot": false, "Dir": ` + jsonStr(localDir) + `, "GoFiles": ["main.go"]}`, + `{"Standard": true, "Goroot": true, "Dir": ` + jsonStr(stdlibDir) + `, "GoFiles": ["print.go"]}`, + `{"Standard": false, "Goroot": false, "Dir": ` + jsonStr(modcacheDir) + `, "GoFiles": ["lib.go"]}`, + `{"Standard": false, "Goroot": false, "Dir": ` + jsonStr(siblingDir) + `, "GoFiles": ["replaced.go"]}`, + }, "\n") + + deps, err := parseGoListOutput(strings.NewReader(stream), gomodcache) + if err != nil { + t.Fatalf("parseGoListOutput: %v", err) + } + + wantKept := []string{ + filepath.Join(localDir, "main.go"), + filepath.Join(siblingDir, "replaced.go"), + } + for _, want := range wantKept { + if _, ok := deps.files[want]; !ok { + t.Errorf("expected %q in files, got %v", want, keys(deps.files)) + } + } + wantDirs := []string{localDir, siblingDir} + for _, want := range wantDirs { + if _, ok := deps.pkgDirs[want]; !ok { + t.Errorf("expected %q in pkgDirs, got %v", want, keys(deps.pkgDirs)) + } + } + for f := range deps.files { + if strings.HasPrefix(f, gomodcache) { + t.Errorf("module-cache file leaked into result: %s", f) + } + if strings.HasPrefix(f, stdlibDir) { + t.Errorf("stdlib file leaked into result: %s", f) + } + } + for d := range deps.pkgDirs { + if strings.HasPrefix(d, gomodcache) { + t.Errorf("module-cache dir leaked into pkgDirs: %s", d) + } + if strings.HasPrefix(d, stdlibDir) { + t.Errorf("stdlib dir leaked into pkgDirs: %s", d) + } + } +} + +func jsonStr(s string) string { + b, _ := json.Marshal(s) + return string(b) +}