package diag
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// fakeServerEnv, when set, turns the test binary into a fake language server
// on its own stdin and stdout. It is how the process-spawning path — pipes,
// kill, wait, a child that exits under us — is tested without a real server:
// the Runner is pointed at a shell script that re-executes this binary with
// the variable set. The value picks the misbehaviour.
const fakeServerEnv = "CODEY_DIAG_FAKE_SERVER"
func TestMain(m *testing.M) {
mode := os.Getenv(fakeServerEnv)
if mode == "" {
os.Exit(m.Run())
}
switch mode {
case "exit":
os.Exit(0)
case "silent":
// Read and discard everything until stdin closes, so the client's
// writes succeed and only its reads go unanswered.
_, _ = bufio.NewReader(os.Stdin).WriteTo(discard{})
os.Exit(0)
}
f := &fakeServer{in: bufio.NewReader(os.Stdin), out: os.Stdout}
f.loop(func(f *fakeServer, msg incoming) {
if mode == "crash" && msg.Method == "textDocument/didOpen" {
os.Exit(3)
}
wellBehaved(f, msg)
})
os.Exit(0)
}
type discard struct{}
func (discard) Write(p []byte) (int, error) { return len(p), nil }
// fakeServerScript writes a shell script that runs this test binary as a fake
// server in mode, and returns its path. Tests that need it skip where there is
// no sh.
func fakeServerScript(t *testing.T, mode string) string {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("needs sh")
}
if _, err := exec.LookPath("sh"); err != nil {
t.Skip("needs sh")
}
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
p := filepath.Join(t.TempDir(), "fake-server")
script := fmt.Sprintf("#!/bin/sh\n%s=%s exec %q \"$@\"\n", fakeServerEnv, mode, self)
if err := os.WriteFile(p, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
return p
}
// runnerWith returns a Runner that believes only the listed programs are on
// PATH, each at the given location.
func runnerWith(t *testing.T, installed map[string]string) *Runner {
t.Helper()
r := NewRunner()
r.lookPath = func(bin string) (string, error) {
if p, ok := installed[bin]; ok {
return p, nil
}
return "", exec.ErrNotFound
}
t.Cleanup(func() {
done := make(chan struct{})
go func() { r.Close(); close(done) }()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Error("Runner.Close hung")
}
})
return r
}
// goModule writes a tiny module with one file and returns the file's path.
func goModule(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module scratch\n\ngo 1.22\n"), 0o644); err != nil {
t.Fatal(err)
}
p := filepath.Join(dir, "main.go")
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return p
}
func TestNilRunnerIsANoOp(t *testing.T) {
var r *Runner
if got := r.Report(context.Background(), "/r", "/r/a.go"); got != "" {
t.Errorf("nil Report = %q", got)
}
if ds, err := r.Check(context.Background(), "/r/a.go"); ds != nil || err != nil {
t.Errorf("nil Check = %v, %v", ds, err)
}
r.Close()
r.Close()
}
func TestRunnerWithNothingInstalledIsSilent(t *testing.T) {
r := runnerWith(t, nil)
p := writeTemp(t, "main.go", "package main\n\nfunc main() { undefined() }\n")
_, err := r.Check(context.Background(), p)
if !errors.Is(err, errNoChecker) {
t.Errorf("err = %v", err)
}
if got := r.Report(context.Background(), filepath.Dir(p), p); got != "" {
t.Errorf("Report = %q", got)
}
if got := r.Report(context.Background(), "/r", "/r/notes.md"); got != "" {
t.Errorf("unknown extension → %q", got)
}
}
func TestRunnerPrefersTheServerAndReusesIt(t *testing.T) {
r := runnerWith(t, map[string]string{"gopls": fakeServerScript(t, "well"), "go": "/usr/bin/go"})
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module m\n"), 0o644); err != nil {
t.Fatal(err)
}
a := filepath.Join(dir, "a.go")
b := filepath.Join(dir, "sub", "b.go")
_ = os.Mkdir(filepath.Dir(b), 0o755)
_ = os.WriteFile(a, []byte("package m\n\nvar x = BUG\n"), 0o644)
_ = os.WriteFile(b, []byte("package sub\n"), 0o644)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
got := r.Report(ctx, dir, a)
want := "diagnostics (gopls): 1 problem\n a.go:3:9: this is a bug"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
if got := r.Report(ctx, dir, b); got != "" {
t.Errorf("clean neighbour → %q", got)
}
r.mu.Lock()
n := len(r.servers)
r.mu.Unlock()
if n != 1 {
t.Errorf("%d servers for one workspace, want 1", n)
}
}
func TestRunnerFallsBackToTheLinterWhenTheServerWillNotStart(t *testing.T) {
if testing.Short() {
t.Skip("runs the go tool")
}
// A "gopls" that is on PATH but exits at once: the shim that points at
// nothing, the binary for the wrong architecture.
r := runnerWith(t, map[string]string{"gopls": fakeServerScript(t, "exit"), "go": "go"})
p := goModule(t, "package main\n\nfunc main() {\n\tn := 3\n}\n")
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
// The first check meets the dead server. It may or may not have noticed
// the exit yet, so its result is not asserted; what matters is that it
// returns and that the key ends up on cooldown.
_, _ = r.Check(ctx, p)
deadline := time.Now().Add(5 * time.Second)
for {
r.mu.Lock()
n := len(r.cooldown)
r.mu.Unlock()
if n == 1 || time.Now().After(deadline) {
break
}
time.Sleep(10 * time.Millisecond)
}
r.mu.Lock()
n := len(r.cooldown)
r.mu.Unlock()
if n != 1 {
t.Fatal("a server that exited before initialize was not put on cooldown")
}
got := r.Report(ctx, filepath.Dir(p), p)
if !strings.HasPrefix(got, "diagnostics (go build): 1 problem\n main.go:4:2: declared and not used: n") {
t.Errorf("linter fallback produced %q", got)
}
}
func TestRunnerSurvivesAServerThatCrashesMidCheck(t *testing.T) {
r := runnerWith(t, map[string]string{"gopls": fakeServerScript(t, "crash")})
p := goModule(t, "package main\n")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
start := time.Now()
if got := r.Report(ctx, filepath.Dir(p), p); got != "" {
t.Errorf("crashed server produced %q", got)
}
if took := time.Since(start); took > checkTimeout+time.Second {
t.Errorf("Report took %s against a crashing server", took)
}
}
func TestRunnerSilentServerCostsTheDeadlineOnceAndCloseKillsIt(t *testing.T) {
r := runnerWith(t, map[string]string{"gopls": fakeServerScript(t, "silent")})
p := goModule(t, "package main\n")
start := time.Now()
if got := r.Report(context.Background(), filepath.Dir(p), p); got != "" {
t.Errorf("silent server produced %q", got)
}
took := time.Since(start)
if took < checkTimeout/2 || took > checkTimeout+time.Second {
t.Errorf("Report against a silent server took %s, expected about %s", took, checkTimeout)
}
r.mu.Lock()
var s *session
for _, s = range r.servers {
}
r.mu.Unlock()
if s == nil {
t.Fatal("no session recorded")
}
start = time.Now()
r.Close()
if took := time.Since(start); took > 3*shutdownGrace {
t.Errorf("Close took %s", took)
}
select {
case <-s.dead:
case <-time.After(time.Second):
t.Error("session not dead after Close")
}
}
func TestReportRecoversFromAPanic(t *testing.T) {
r := NewRunner()
r.lookPath = func(string) (string, error) { panic("lookup exploded") }
if got := r.Report(context.Background(), "/r", "/r/a.go"); got != "" {
t.Errorf("Report = %q", got)
}
}
func TestWorkspaceRoot(t *testing.T) {
top := t.TempDir()
mod := filepath.Join(top, "mod")
deep := filepath.Join(mod, "a", "b")
if err := os.MkdirAll(deep, 0o755); err != nil {
t.Fatal(err)
}
_ = os.WriteFile(filepath.Join(mod, "go.mod"), []byte("module m\n"), 0o644)
_ = os.Mkdir(filepath.Join(top, ".git"), 0o755)
if got := workspaceRoot(filepath.Join(deep, "x.go"), []string{"go.mod"}); got != mod {
t.Errorf("marker: %q, want %q", got, mod)
}
if got := workspaceRoot(filepath.Join(deep, "x.rs"), []string{"Cargo.toml"}); got != top {
t.Errorf(".git fallback: %q, want %q", got, top)
}
loose := t.TempDir()
if got := workspaceRoot(filepath.Join(loose, "x.py"), []string{"pyproject.toml"}); got != loose {
t.Errorf("no markers: %q, want %q", got, loose)
}
}