diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index de918b1568..5ee7800462 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -106,17 +106,7 @@ jobs: run: | set -euo pipefail - packages="$(go list -f '{{.ImportPath}}|{{.Dir}}' ./... | awk -F '|' -v root="$PWD" ' - { - if (index($2, root) != 1 || (length($2) > length(root) && substr($2, length(root) + 1, 1) != "/")) { - print "Package directory is outside the repository: " $2 > "/dev/stderr" - exit 1 - } - - relative = substr($2, length(root) + 1) - if (relative !~ /(^|\/)(test|e2e)(\/|$)/) print $1 - } - ')" + packages="$(go run ./hack/unit_test_packages)" if [[ -z "$packages" ]]; then echo "No unit-test packages found" diff --git a/Taskfile.yml b/Taskfile.yml index 5b8c9f89f1..ec689e9faf 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -108,7 +108,10 @@ tasks: cli:test: desc: run devsy unit tests - cmd: go test $(go list ./... | grep -v -e /test -e /e2e) -race -coverprofile=dist/profile.out -covermode=atomic + cmd: | + set -euo pipefail + packages="$(go run ./hack/unit_test_packages)" + go test -race -coverprofile=dist/profile.out -covermode=atomic $packages cli:test:e2e:build: desc: build devsy for e2e tests diff --git a/hack/unit_test_packages/main.go b/hack/unit_test_packages/main.go new file mode 100644 index 0000000000..fc96051617 --- /dev/null +++ b/hack/unit_test_packages/main.go @@ -0,0 +1,97 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +const goListFormat = "{{.ImportPath}}|{{.Dir}}" + +var ( + errNoUnitTestPackages = errors.New("no unit-test packages found") + errOutsideRepository = errors.New("package directory is outside the repository") + errMalformedGoList = errors.New("unexpected go list line") + excludedPackagePattern = regexp.MustCompile(`(^|/)(test|e2e)(/|$)`) +) + +func main() { + os.Exit(runCLI(os.Args[1:], os.Stdout, os.Stderr)) +} + +func runCLI(args []string, stdout, stderr io.Writer) int { + if len(args) != 0 { + _, _ = fmt.Fprintln(stderr, "usage: unit_test_packages (run from the repository root)") + return 2 + } + root, err := os.Getwd() + if err != nil { + _, _ = fmt.Fprintln(stderr, err) + return 1 + } + goList, err := runGoList(stderr) + if err != nil { + _, _ = fmt.Fprintln(stderr, err) + return 1 + } + packages, err := filterUnitTestPackages(goList, root) + if err != nil { + _, _ = fmt.Fprintln(stderr, err) + return 1 + } + for _, p := range packages { + _, _ = fmt.Fprintln(stdout, p) + } + return 0 +} + +func runGoList(stderr io.Writer) (string, error) { + var out bytes.Buffer + cmd := exec.Command("go", "list", "-f", goListFormat, "./...") + cmd.Stdout = &out + cmd.Stderr = stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("go list: %w", err) + } + return out.String(), nil +} + +// filterUnitTestPackages takes `go list -f '{{.ImportPath}}|{{.Dir}}'` output +// and returns the import paths of packages under root, skipping any package +// whose directory has a test or e2e path segment below root. +func filterUnitTestPackages(goList, root string) ([]string, error) { + var packages []string + for line := range strings.SplitSeq(goList, "\n") { + if line == "" { + continue + } + importPath, dir, ok := strings.Cut(line, "|") + if !ok { + return nil, fmt.Errorf("%w: %q", errMalformedGoList, line) + } + if !isUnderRoot(dir, root) { + return nil, fmt.Errorf("%w: %s", errOutsideRepository, dir) + } + relative := filepath.ToSlash(strings.TrimPrefix(dir, root)) + if excludedPackagePattern.MatchString(relative) { + continue + } + packages = append(packages, importPath) + } + if len(packages) == 0 { + return nil, errNoUnitTestPackages + } + return packages, nil +} + +// isUnderRoot reports whether dir is root or a directory inside it. +// A sibling sharing root as a string prefix (root+"2") is not under root. +func isUnderRoot(dir, root string) bool { + return dir == root || strings.HasPrefix(dir, root+string(filepath.Separator)) +} diff --git a/hack/unit_test_packages/main_test.go b/hack/unit_test_packages/main_test.go new file mode 100644 index 0000000000..a1ed80a507 --- /dev/null +++ b/hack/unit_test_packages/main_test.go @@ -0,0 +1,133 @@ +package main + +import ( + "errors" + "path/filepath" + "slices" + "strings" + "testing" +) + +// filterFixture returns a repo root that does not exist on disk and a helper +// that joins slash-separated relative paths onto it. +func filterFixture(t *testing.T) (root string, at func(string) string) { + t.Helper() + root = filepath.Join(t.TempDir(), "devsy") + at = func(rel string) string { return filepath.Join(root, filepath.FromSlash(rel)) } + return root, at +} + +func TestFilterUnitTestPackagesKeepsPackages(t *testing.T) { + t.Parallel() + + root, at := filterFixture(t) + + tests := []struct { + name string + goList string + want []string + }{ + { + name: "keeps package under root", + goList: "example.com/devsy/pkg/foo|" + at("pkg/foo") + "\n", + want: []string{"example.com/devsy/pkg/foo"}, + }, + { + name: "keeps root package", + goList: "example.com/devsy|" + root + "\n", + want: []string{"example.com/devsy"}, + }, + { + name: "excludes test and e2e directories", + goList: strings.Join([]string{ + "example.com/devsy/test/helpers|" + at("test/helpers"), + "example.com/devsy/pkg/test|" + at("pkg/test"), + "example.com/devsy/e2e|" + at("e2e"), + "example.com/devsy/e2e/framework|" + at("e2e/framework"), + "example.com/devsy/pkg/e2e/x|" + at("pkg/e2e/x"), + "example.com/devsy/pkg/foo|" + at("pkg/foo"), + }, "\n") + "\n", + want: []string{"example.com/devsy/pkg/foo"}, + }, + { + name: "keeps names that only contain test or e2e", + goList: strings.Join([]string{ + "example.com/devsy/pkg/testing|" + at("pkg/testing"), + "example.com/devsy/pkg/tests|" + at("pkg/tests"), + "example.com/devsy/pkg/e2equal|" + at("pkg/e2equal"), + }, "\n") + "\n", + want: []string{ + "example.com/devsy/pkg/testing", + "example.com/devsy/pkg/tests", + "example.com/devsy/pkg/e2equal", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := filterUnitTestPackages(tc.goList, root) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !slices.Equal(got, tc.want) { + t.Fatalf("packages = %v, want %v", got, tc.want) + } + }) + } +} + +func TestFilterUnitTestPackagesRejects(t *testing.T) { + t.Parallel() + + root, at := filterFixture(t) + sibling := root + "2" + + tests := []struct { + name string + goList string + wantErr error + }{ + { + name: "rejects sibling sharing root prefix", + goList: "example.com/x|" + filepath.Join(sibling, "x") + "\n", + wantErr: errOutsideRepository, + }, + { + name: "rejects directory outside root", + goList: "example.com/elsewhere|" + filepath.Join( + filepath.Dir(root), + "elsewhere", + ) + "\n", + wantErr: errOutsideRepository, + }, + { + name: "rejects listing where every package is excluded", + goList: "example.com/devsy/e2e|" + at("e2e") + "\n", + wantErr: errNoUnitTestPackages, + }, + { + name: "rejects empty listing", + goList: "", + wantErr: errNoUnitTestPackages, + }, + { + name: "rejects line without separator", + goList: "example.com/devsy/pkg/foo\n", + wantErr: errMalformedGoList, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, err := filterUnitTestPackages(tc.goList, root) + if !errors.Is(err, tc.wantErr) { + t.Fatalf("error = %v, want %v", err, tc.wantErr) + } + }) + } +}