Vectrum
Sign In
280 lines · 8.2 KB · Go
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)
	}
}