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) } }