shad-go/tools/testtool/commands/test_submission.go

466 lines
12 KiB
Go
Raw Normal View History

package commands
import (
2020-02-21 22:23:20 +00:00
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
2020-02-12 22:54:25 +00:00
"fmt"
"io/ioutil"
"log"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"github.com/spf13/cobra"
2020-02-21 22:23:20 +00:00
"golang.org/x/perf/benchstat"
2020-02-12 22:25:12 +00:00
"gitlab.com/slon/shad-go/tools/testtool"
)
const (
problemFlag = "problem"
studentRepoFlag = "student-repo"
privateRepoFlag = "private-repo"
testdataDir = "testdata"
moduleImportPath = "gitlab.com/slon/shad-go"
)
var testSubmissionCmd = &cobra.Command{
2020-02-12 22:54:25 +00:00
Use: "check-task",
Short: "test single task",
Run: func(cmd *cobra.Command, args []string) {
problem, err := cmd.Flags().GetString(problemFlag)
if err != nil {
log.Fatal(err)
}
studentRepo := mustParseDirFlag(studentRepoFlag, cmd)
if !problemDirExists(studentRepo, problem) {
log.Fatalf("%s does not have %s directory", studentRepo, problem)
}
privateRepo := mustParseDirFlag(privateRepoFlag, cmd)
if !problemDirExists(privateRepo, problem) {
log.Fatalf("%s does not have %s directory", privateRepo, problem)
}
2020-02-12 22:54:25 +00:00
if err := testSubmission(studentRepo, privateRepo, problem); err != nil {
log.Fatal(err)
}
},
}
func init() {
rootCmd.AddCommand(testSubmissionCmd)
testSubmissionCmd.Flags().String(problemFlag, "", "problem directory name (required)")
_ = testSubmissionCmd.MarkFlagRequired(problemFlag)
testSubmissionCmd.Flags().String(studentRepoFlag, ".", "path to student repo root")
testSubmissionCmd.Flags().String(privateRepoFlag, ".", "path to shad-go-private repo root")
}
// mustParseDirFlag parses string directory flag with given name.
//
// Exits on any error.
func mustParseDirFlag(name string, cmd *cobra.Command) string {
dir, err := cmd.Flags().GetString(name)
if err != nil {
log.Fatal(err)
}
dir, err = filepath.Abs(dir)
if err != nil {
log.Fatal(err)
}
return dir
}
// Check that repo dir contains problem subdir.
func problemDirExists(repo, problem string) bool {
info, err := os.Stat(path.Join(repo, problem))
if err != nil {
return false
}
return info.IsDir()
}
2020-02-12 22:54:25 +00:00
func testSubmission(studentRepo, privateRepo, problem string) error {
// Create temp directory to store all files required to test the solution.
tmpRepo, err := ioutil.TempDir("/tmp", problem+"-")
if err != nil {
log.Fatal(err)
}
if err := os.Chmod(tmpRepo, 0755); err != nil {
log.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpRepo) }()
log.Printf("testing submission in %s", tmpRepo)
// Path to private problem folder.
privateProblem := path.Join(privateRepo, problem)
// Copy student repo files to temp dir.
log.Printf("copying student repo")
copyContents(studentRepo, ".", tmpRepo)
// Copy tests from private repo to temp dir.
log.Printf("copying tests")
tests := listTestFiles(privateProblem)
copyFiles(privateRepo, relPaths(privateRepo, tests), tmpRepo)
// Copy !change files from private repo to temp dir.
log.Printf("copying !change files")
protected := listProtectedFiles(privateProblem)
copyFiles(privateRepo, relPaths(privateRepo, protected), tmpRepo)
// Copy testdata directory from private repo to temp dir.
log.Printf("copying testdata directory")
copyDir(privateRepo, path.Join(problem, testdataDir), tmpRepo)
// Copy go.mod and go.sum from private repo to temp dir.
2020-02-14 12:55:10 +00:00
log.Printf("copying go.mod, go.sum and .golangci.yml")
copyFiles(privateRepo, []string{"go.mod", "go.sum", ".golangci.yml"}, tmpRepo)
2020-02-21 22:23:20 +00:00
log.Printf("running tests")
if err := runTests(tmpRepo, privateRepo, problem); err != nil {
return err
}
2020-02-14 12:55:10 +00:00
log.Printf("running linter")
if err := runLinter(tmpRepo, problem); err != nil {
return err
}
2020-02-21 22:23:20 +00:00
return nil
}
// copyDir recursively copies src directory to dst.
func copyDir(baseDir, src, dst string) {
_, err := os.Stat(src)
if os.IsNotExist(err) {
return
}
cmd := exec.Command("rsync", "-prR", src, dst)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Dir = baseDir
if err := cmd.Run(); err != nil {
log.Fatalf("directory copying failed: %s", err)
}
}
// copyContents recursively copies src contents to dst.
func copyContents(baseDir, src, dst string) {
copyDir(baseDir, src+"/", dst)
}
// copyFiles copies files preserving directory structure relative to baseDir.
//
// Existing files get replaced.
func copyFiles(baseDir string, relPaths []string, dst string) {
for _, p := range relPaths {
cmd := exec.Command("rsync", "-prR", p, dst)
cmd.Dir = baseDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Fatalf("file copying failed: %s", err)
}
}
}
func randomName() string {
var raw [8]byte
_, _ = rand.Read(raw[:])
return hex.EncodeToString(raw[:])
}
2020-02-12 22:54:25 +00:00
type TestFailedError struct {
E error
}
func (e *TestFailedError) Error() string {
2020-02-12 23:31:40 +00:00
return fmt.Sprintf("test failed: %v", e.E)
2020-02-12 22:54:25 +00:00
}
func (e *TestFailedError) Unwrap() error {
return e.E
}
2020-02-14 12:55:10 +00:00
func runLinter(testDir, problem string) error {
cmd := exec.Command("golangci-lint", "run", "--modules-download-mode", "readonly", "--build-tags", "private", fmt.Sprintf("./%s/...", problem))
cmd.Dir = testDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("linter failed: %w", err)
}
return nil
}
// runTests runs all tests in directory with race detector.
2020-02-21 22:23:20 +00:00
func runTests(testDir, privateRepo, problem string) error {
binCache, err := ioutil.TempDir("/tmp", "bincache")
if err != nil {
log.Fatal(err)
}
2021-04-30 10:33:35 +00:00
if err = os.Chmod(binCache, 0755); err != nil {
log.Fatal(err)
}
2021-04-30 10:10:10 +00:00
var goCache string
goCache, err = ioutil.TempDir("/tmp", "gocache")
2021-04-30 10:00:54 +00:00
if err != nil {
log.Fatal(err)
}
2021-04-30 10:33:35 +00:00
if err = os.Chmod(goCache, 0777); err != nil {
2021-04-30 10:00:54 +00:00
log.Fatal(err)
}
2020-02-12 22:54:25 +00:00
runGo := func(arg ...string) error {
log.Printf("> go %s", strings.Join(arg, " "))
cmd := exec.Command("go", arg...)
cmd.Env = append(os.Environ(), "GOFLAGS=")
cmd.Dir = testDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
2020-02-12 22:54:25 +00:00
return cmd.Run()
}
2022-03-12 13:25:16 +00:00
var (
binaries = make(map[string]string)
testBinaries = make(map[string]string)
raceBinaries = make(map[string]string)
)
2022-02-10 22:06:57 +00:00
//binPkgs, testPkgs := listTestsAndBinaries(filepath.Join(testDir, problem), []string{"-tags", "private", "-mod", "readonly"}) // todo return readonly
binPkgs, testPkgs := listTestsAndBinaries(filepath.Join(testDir, problem), []string{"-tags", "private"})
for binaryPkg := range binPkgs {
binPath := filepath.Join(binCache, randomName())
binaries[binaryPkg] = binPath
2020-02-12 22:54:25 +00:00
if err := runGo("build", "-mod", "readonly", "-tags", "private", "-o", binPath, binaryPkg); err != nil {
return fmt.Errorf("error building binary in %s: %w", binaryPkg, err)
}
}
coverageReq := getCoverageRequirements(path.Join(privateRepo, problem))
coveragePackages := []string{}
if coverageReq.Enabled {
log.Printf("required coverage: %.2f%%", coverageReq.Percent)
for _, pkg := range coverageReq.Packages {
coveragePackages = append(coveragePackages, path.Join(moduleImportPath, problem, pkg))
}
}
2020-02-12 22:25:12 +00:00
binariesJSON, _ := json.Marshal(binaries)
for testPkg := range testPkgs {
2022-03-12 13:25:16 +00:00
testPath := filepath.Join(binCache, randomName())
testBinaries[testPkg] = testPath
cmd := []string{"test", "-mod", "readonly", "-tags", "private", "-c", "-o", testPath, testPkg}
if coverageReq.Enabled {
cmd = append(cmd, "-cover", "-coverpkg", strings.Join(coveragePackages, ","))
}
if err := runGo(cmd...); err != nil {
2020-02-12 22:54:25 +00:00
return fmt.Errorf("error building test in %s: %w", testPkg, err)
}
2022-03-12 13:25:16 +00:00
racePath := filepath.Join(binCache, randomName())
raceBinaries[testPkg] = racePath
cmd = []string{"test", "-mod", "readonly", "-race", "-tags", "private", "-c", "-o", racePath, testPkg}
if err := runGo(cmd...); err != nil {
return fmt.Errorf("error building test in %s: %w", testPkg, err)
}
}
coverProfiles := []string{}
for testPkg, testBinary := range testBinaries {
relPath := strings.TrimPrefix(testPkg, moduleImportPath)
coverProfile := path.Join(os.TempDir(), randomName())
2020-02-21 22:23:20 +00:00
{
cmd := exec.Command(testBinary)
if coverageReq.Enabled {
cmd = exec.Command(testBinary, "-test.coverprofile", coverProfile)
coverProfiles = append(coverProfiles, coverProfile)
}
2020-02-21 22:23:20 +00:00
if currentUserIsRoot() {
if err := sandbox(cmd); err != nil {
log.Fatal(err)
}
}
cmd.Dir = filepath.Join(testDir, relPath)
2020-04-21 13:21:00 +00:00
cmd.Env = []string{
testtool.BinariesEnv + "=" + string(binariesJSON),
"PATH=" + os.Getenv("PATH"),
2021-04-30 09:30:53 +00:00
"HOME=" + os.Getenv("HOME"),
2021-04-30 10:00:54 +00:00
"GOCACHE=" + goCache,
2020-04-21 13:21:00 +00:00
}
2020-02-21 22:23:20 +00:00
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return &TestFailedError{E: err}
2020-02-13 00:02:31 +00:00
}
}
2022-03-12 13:25:16 +00:00
{
cmd := exec.Command(raceBinaries[testPkg], "-test.bench=.")
if currentUserIsRoot() {
if err := sandbox(cmd); err != nil {
log.Fatal(err)
}
}
cmd.Dir = filepath.Join(testDir, relPath)
cmd.Env = []string{
testtool.BinariesEnv + "=" + string(binariesJSON),
"PATH=" + os.Getenv("PATH"),
"HOME=" + os.Getenv("HOME"),
"GOCACHE=" + goCache,
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return &TestFailedError{E: err}
}
}
2020-02-21 22:23:20 +00:00
{
benchCmd := exec.Command(testBinary, "-test.bench=.", "-test.run=^$")
if currentUserIsRoot() {
if err := sandbox(benchCmd); err != nil {
log.Fatal(err)
}
}
2020-02-21 22:23:20 +00:00
var buf bytes.Buffer
benchCmd.Dir = filepath.Join(testDir, relPath)
2020-04-21 13:21:00 +00:00
benchCmd.Env = []string{
testtool.BinariesEnv + "=" + string(binariesJSON),
"PATH=" + os.Getenv("PATH"),
2021-04-30 09:30:53 +00:00
"HOME=" + os.Getenv("HOME"),
2021-04-30 10:00:54 +00:00
"GOCACHE=" + goCache,
2020-04-21 13:21:00 +00:00
}
2020-02-21 22:23:20 +00:00
benchCmd.Stdout = &buf
benchCmd.Stderr = os.Stderr
if err := benchCmd.Run(); err != nil {
return &TestFailedError{E: err}
}
if strings.Contains(buf.String(), "no tests to run") {
continue
}
if err := compareToBaseline(testPkg, privateRepo, buf.Bytes()); err != nil {
return err
}
}
}
if coverageReq.Enabled {
log.Printf("checking coverage is at least %.2f%%...", coverageReq.Percent)
// For some reason, this command will record all coverage blocks in coverpkg,
// even if no test binaries depend on given package.
// Hacky way to record all the code present in problem definition.
targetProfile := path.Join(os.TempDir(), randomName())
coverCmd := exec.Command("go",
"test",
"-coverpkg", strings.Join(coveragePackages, ","),
"-coverprofile", targetProfile,
"-run", "^$",
"./...",
)
coverCmd.Env = append(os.Environ(), "GOFLAGS=")
coverCmd.Dir = path.Join(privateRepo, problem)
coverCmd.Stderr = os.Stderr
log.Printf("> %s", strings.Join(coverCmd.Args, " "))
if err := coverCmd.Run(); err != nil {
return fmt.Errorf("error getting target coverage profile: %w", err)
}
percent, err := calCoverage(targetProfile, coverProfiles)
if err != nil {
return err
}
log.Printf("coverage is %.2f%%", percent)
if percent < coverageReq.Percent {
return fmt.Errorf("poor coverage %.2f%%; expected at least %.2f%%",
percent, coverageReq.Percent)
}
}
2020-02-21 22:23:20 +00:00
return nil
}
func noMoreThanTwoTimesWorse(old, new *benchstat.Metrics) (float64, error) {
if new.Mean > 1.99*old.Mean {
2020-02-21 22:23:20 +00:00
return 0.0, nil
}
return 1.0, nil
}
func compareToBaseline(testPkg, privateRepo string, run []byte) error {
var buf bytes.Buffer
2022-03-31 17:23:32 +00:00
goTest := exec.Command("go", "test", "-tags", "private,solution", "-bench=.", "-run=^$", testPkg)
2020-02-21 22:23:20 +00:00
goTest.Dir = privateRepo
goTest.Stdout = &buf
goTest.Stderr = os.Stderr
if err := goTest.Run(); err != nil {
return fmt.Errorf("baseline benchmark failed: %w", err)
}
c := &benchstat.Collection{
DeltaTest: noMoreThanTwoTimesWorse,
}
c.AddConfig("baseline.txt", buf.Bytes())
c.AddConfig("new.txt", run)
tables := c.Tables()
benchstat.FormatText(os.Stderr, tables)
for _, c := range tables {
for _, r := range c.Rows {
if r.Change == -1 {
2020-02-22 12:37:16 +00:00
return fmt.Errorf("solution is worse than baseline on benchmark %q", r.Benchmark)
2020-02-21 22:23:20 +00:00
}
}
}
2020-02-12 22:54:25 +00:00
return nil
}
// relPaths converts paths to relative (to the baseDir) ones.
func relPaths(baseDir string, paths []string) []string {
ret := make([]string, len(paths))
for i, p := range paths {
relPath, err := filepath.Rel(baseDir, p)
if err != nil {
log.Fatal(err)
}
ret[i] = relPath
}
return ret
}