package lintgate // Observes the exit status of `task lint` (the lint step of `task check`), not the lint // rules themselves. It runs the repo's real Taskfile in a scratch module so a change to the // lint command that stops it failing on findings (#41) turns this red. import ( "bytes" "errors" "os" "os/exec" "path/filepath" "regexp" "strings" "testing" ) const scratchWithFinding = `package main import "os" func main() { os.Remove("x") } ` func scratchModule(t *testing.T) string { t.Helper() taskfile, err := os.ReadFile(filepath.Join("..", "..", "Taskfile.yml")) if err != nil { t.Fatalf("read Taskfile.yml: %v", err) } dir := t.TempDir() for name, body := range map[string]string{ "Taskfile.yml": string(taskfile), "go.mod": "module scratch\n\ngo 1.21\n", "main.go": scratchWithFinding, } { if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { t.Fatalf("write %s: %v", name, err) } } return dir } // ansi matches the colour escape sequences task prints when it thinks it has a terminal. var ansi = regexp.MustCompile("\x1b\\[[0-9;]*m") // hasSkipNotice reports whether the skip notice was printed as output. Task echoes the command // line first, and that line contains the same words, so a substring match would be vacuous. func hasSkipNotice(out string) bool { for _, line := range strings.Split(ansi.ReplaceAllString(out, ""), "\n") { if strings.HasPrefix(line, "golangci-lint not installed") { return true } } return false } // runLint runs `task lint` in dir with the given PATH and returns its combined output and exit code. func runLint(t *testing.T, dir, path string) (string, int) { t.Helper() return runTask(t, dir, path, "lint") } func runTask(t *testing.T, dir, path string, args ...string) (string, int) { t.Helper() taskBin, err := exec.LookPath("task") if err != nil { t.Skip("task not installed") } cmd := exec.Command(taskBin, args...) cmd.Dir = dir cmd.Env = append(os.Environ(), "PATH="+path) var out bytes.Buffer cmd.Stdout, cmd.Stderr = &out, &out err = cmd.Run() var exitErr *exec.ExitError switch { case err == nil: return out.String(), 0 case errors.As(err, &exitErr): return out.String(), exitErr.ExitCode() default: t.Fatalf("run task lint: %v", err) return "", -1 } } func TestTaskLint_ExitsNonZeroWhenGolangciLintReportsAFinding(t *testing.T) { if _, err := exec.LookPath("golangci-lint"); err != nil { t.Skip("golangci-lint not installed") } out, code := runLint(t, scratchModule(t), os.Getenv("PATH")) if code == 0 { t.Fatalf("task lint exited 0 on a module with an unchecked error; output:\n%s", out) } if hasSkipNotice(out) { t.Fatalf("task lint blamed a missing binary for a lint failure; output:\n%s", out) } if !strings.Contains(out, "errcheck") { t.Fatalf("a non-zero exit without the finding means the linter crashed, not that it found something; output:\n%s", out) } } // The lint step only gates anything if `task check` runs it. func TestTaskCheck_RunsTheLintStep(t *testing.T) { out, code := runTask(t, scratchModule(t), os.Getenv("PATH"), "--dry", "check") if code != 0 { t.Fatalf("task --dry check exited %d; output:\n%s", code, out) } if !strings.Contains(out, "task: [lint]") { t.Fatalf("task check no longer runs the lint step; output:\n%s", out) } } func TestTaskLint_SkipsWithANoticeWhenGolangciLintIsAbsent(t *testing.T) { taskBin, err := exec.LookPath("task") if err != nil { t.Skip("task not installed") } bin := t.TempDir() // a PATH holding task and nothing else if err := os.Symlink(taskBin, filepath.Join(bin, "task")); err != nil { t.Fatalf("symlink task: %v", err) } out, code := runLint(t, scratchModule(t), bin) if code != 0 { t.Fatalf("task lint exited %d without golangci-lint on PATH; output:\n%s", code, out) } if !hasSkipNotice(out) { t.Fatalf("expected the skip notice; output:\n%s", out) } } // CI runs task with colour on, so the notice line starts with an escape sequence; a bare // prefix match missed it there while passing on a terminal-less local run. func TestHasSkipNotice_SeesTheNoticeBehindColourCodes(t *testing.T) { out := "\x1b[32mtask: [lint] ...\n\x1b[0mgolangci-lint not installed — skipping (CI installs it)\n" if !hasSkipNotice(out) { t.Fatalf("notice not found in coloured output: %q", out) } if hasSkipNotice("\x1b[32mtask: [lint] if command -v golangci-lint ...; echo \"golangci-lint not installed\"\n") { t.Fatal("the echoed command line must not count as the notice") } }