// Copyright 2020 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. // Package regtest provides an environment for writing regression tests. package regtest import ( "bytes" "context" "fmt" "io" "io/ioutil" "os" "os/exec" "path/filepath" "strings" "sync" "testing" "time" "golang.org/x/tools/internal/jsonrpc2" "golang.org/x/tools/internal/jsonrpc2/servertest" "golang.org/x/tools/internal/lsp/cache" "golang.org/x/tools/internal/lsp/debug" "golang.org/x/tools/internal/lsp/fake" "golang.org/x/tools/internal/lsp/lsprpc" "golang.org/x/tools/internal/lsp/protocol" ) // EnvMode is a bitmask that defines in which execution environments a test // should run. type EnvMode int const ( // Singleton mode uses a separate cache for each test. Singleton EnvMode = 1 << iota // Forwarded forwards connections to an in-process gopls instance. Forwarded // SeparateProcess runs a separate gopls process, and forwards connections to // it. SeparateProcess // NormalModes runs tests in all modes. NormalModes = Singleton | Forwarded ) // A Runner runs tests in gopls execution environments, as specified by its // modes. For modes that share state (for example, a shared cache or common // remote), any tests that execute on the same Runner will share the same // state. type Runner struct { defaultModes EnvMode timeout time.Duration goplsPath string mu sync.Mutex ts *servertest.TCPServer socketDir string } // NewTestRunner creates a Runner with its shared state initialized, ready to // run tests. func NewTestRunner(modes EnvMode, testTimeout time.Duration, goplsPath string) *Runner { return &Runner{ defaultModes: modes, timeout: testTimeout, goplsPath: goplsPath, } } // Modes returns the bitmask of environment modes this runner is configured to // test. func (r *Runner) Modes() EnvMode { return r.defaultModes } // getTestServer gets the test server instance to connect to, or creates one if // it doesn't exist. func (r *Runner) getTestServer() *servertest.TCPServer { r.mu.Lock() defer r.mu.Unlock() if r.ts == nil { ctx := context.Background() ctx = debug.WithInstance(ctx, "", "") ss := lsprpc.NewStreamServer(cache.New(ctx, nil)) r.ts = servertest.NewTCPServer(context.Background(), ss) } return r.ts } // runTestAsGoplsEnvvar triggers TestMain to run gopls instead of running // tests. It's a trick to allow tests to find a binary to use to start a gopls // subprocess. const runTestAsGoplsEnvvar = "_GOPLS_TEST_BINARY_RUN_AS_GOPLS" func (r *Runner) getRemoteSocket(t *testing.T) string { t.Helper() r.mu.Lock() defer r.mu.Unlock() const daemonFile = "gopls-test-daemon" if r.socketDir != "" { return filepath.Join(r.socketDir, daemonFile) } if r.goplsPath == "" { t.Fatal("cannot run tests with a separate process unless a path to a gopls binary is configured") } var err error r.socketDir, err = ioutil.TempDir("", "gopls-regtests") if err != nil { t.Fatalf("creating tempdir: %v", err) } socket := filepath.Join(r.socketDir, daemonFile) args := []string{"serve", "-listen", "unix;" + socket, "-listen.timeout", "10s"} cmd := exec.Command(r.goplsPath, args...) cmd.Env = append(os.Environ(), runTestAsGoplsEnvvar+"=true") var stderr bytes.Buffer cmd.Stderr = &stderr go func() { if err := cmd.Run(); err != nil { panic(fmt.Sprintf("error running external gopls: %v\nstderr:\n%s", err, stderr.String())) } }() return socket } // Close cleans up resource that have been allocated to this workspace. func (r *Runner) Close() error { r.mu.Lock() defer r.mu.Unlock() if r.ts != nil { r.ts.Close() } if r.socketDir != "" { os.RemoveAll(r.socketDir) } return nil } // Run executes the test function in the default configured gopls execution // modes. For each a test run, a new workspace is created containing the // un-txtared files specified by filedata. func (r *Runner) Run(t *testing.T, filedata string, test func(e *Env)) { t.Helper() r.RunInMode(r.defaultModes, t, filedata, test) } // RunInMode runs the test in the execution modes specified by the modes bitmask. func (r *Runner) RunInMode(modes EnvMode, t *testing.T, filedata string, test func(e *Env)) { t.Helper() tests := []struct { name string mode EnvMode getServer func(context.Context, *testing.T) jsonrpc2.StreamServer }{ {"singleton", Singleton, singletonEnv}, {"forwarded", Forwarded, r.forwardedEnv}, {"separate_process", SeparateProcess, r.separateProcessEnv}, } for _, tc := range tests { tc := tc if modes&tc.mode == 0 { continue } t.Run(tc.name, func(t *testing.T) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), r.timeout) defer cancel() ctx = debug.WithInstance(ctx, "", "") ws, err := fake.NewWorkspace("lsprpc", []byte(filedata)) if err != nil { t.Fatal(err) } defer ws.Close() ss := tc.getServer(ctx, t) ls := &loggingServer{delegate: ss} ts := servertest.NewPipeServer(ctx, ls) defer func() { ts.Close() }() env := NewEnv(ctx, t, ws, ts) defer func() { if t.Failed() { ls.printBuffers(t.Name(), os.Stderr) } if err := env.E.Shutdown(ctx); err != nil { panic(err) } }() test(env) }) } } type loggingServer struct { delegate jsonrpc2.StreamServer mu sync.Mutex buffers []*bytes.Buffer } func (s *loggingServer) ServeStream(ctx context.Context, stream jsonrpc2.Stream) error { s.mu.Lock() var buf bytes.Buffer s.buffers = append(s.buffers, &buf) s.mu.Unlock() logStream := protocol.LoggingStream(stream, &buf) return s.delegate.ServeStream(ctx, logStream) } func (s *loggingServer) printBuffers(testname string, w io.Writer) { s.mu.Lock() defer s.mu.Unlock() for i, buf := range s.buffers { fmt.Fprintf(os.Stderr, "#### Start Gopls Test Logs %d of %d for %q\n", i+1, len(s.buffers), testname) io.Copy(w, buf) fmt.Fprintf(os.Stderr, "#### End Gopls Test Logs %d of %d for %q\n", i+1, len(s.buffers), testname) } } func singletonEnv(ctx context.Context, t *testing.T) jsonrpc2.StreamServer { return lsprpc.NewStreamServer(cache.New(ctx, nil)) } func (r *Runner) forwardedEnv(ctx context.Context, t *testing.T) jsonrpc2.StreamServer { ts := r.getTestServer() return lsprpc.NewForwarder("tcp", ts.Addr) } func (r *Runner) separateProcessEnv(ctx context.Context, t *testing.T) jsonrpc2.StreamServer { // TODO(rfindley): can we use the autostart behavior here, instead of // pre-starting the remote? socket := r.getRemoteSocket(t) return lsprpc.NewForwarder("unix", socket) } // Env holds an initialized fake Editor, Workspace, and Server, which may be // used for writing tests. It also provides adapter methods that call t.Fatal // on any error, so that tests for the happy path may be written without // checking errors. type Env struct { T *testing.T Ctx context.Context // Most tests should not need to access the workspace, editor, server, or // connection, but they are available if needed. W *fake.Workspace E *fake.Editor Server servertest.Connector Conn *jsonrpc2.Conn // mu guards the fields below, for the purpose of checking conditions on // every change to diagnostics. mu sync.Mutex // For simplicity, each waiter gets a unique ID. nextWaiterID int lastDiagnostics map[string]*protocol.PublishDiagnosticsParams waiters map[int]*diagnosticCondition } // A diagnosticCondition is satisfied when all expectations are simultaneously // met. At that point, the 'met' channel is closed. On any failure, err is set // and the failed channel is closed. type diagnosticCondition struct { expectations []DiagnosticExpectation met chan struct{} } // NewEnv creates a new test environment using the given workspace and gopls // server. func NewEnv(ctx context.Context, t *testing.T, ws *fake.Workspace, ts servertest.Connector) *Env { t.Helper() conn := ts.Connect(ctx) editor, err := fake.NewConnectedEditor(ctx, ws, conn) if err != nil { t.Fatal(err) } env := &Env{ T: t, Ctx: ctx, W: ws, E: editor, Server: ts, Conn: conn, lastDiagnostics: make(map[string]*protocol.PublishDiagnosticsParams), waiters: make(map[int]*diagnosticCondition), } env.E.Client().OnDiagnostics(env.onDiagnostics) return env } func (e *Env) onDiagnostics(_ context.Context, d *protocol.PublishDiagnosticsParams) error { e.mu.Lock() defer e.mu.Unlock() pth := e.W.URIToPath(d.URI) e.lastDiagnostics[pth] = d for id, condition := range e.waiters { if meetsExpectations(e.lastDiagnostics, condition.expectations) { delete(e.waiters, id) close(condition.met) } } return nil } // ExpectDiagnostics asserts that the current diagnostics in the editor match // the given expectations. It is intended to be used together with Env.Await to // allow waiting on simpler diagnostic expectations (for example, // AnyDiagnosticsACurrenttVersion), followed by more detailed expectations // tested by ExpectDiagnostics. // // For example: // env.RegexpReplace("foo.go", "a", "x") // env.Await(env.AnyDiagnosticAtCurrentVersion("foo.go")) // env.ExpectDiagnostics(env.DiagnosticAtRegexp("foo.go", "x")) // // This has the advantage of not timing out if the diagnostic received for // "foo.go" does not match the expectation: instead it fails early. func (e *Env) ExpectDiagnostics(expectations ...DiagnosticExpectation) { e.T.Helper() e.mu.Lock() defer e.mu.Unlock() if !meetsExpectations(e.lastDiagnostics, expectations) { e.T.Fatalf("diagnostic are unmet:\n%s\nlast diagnostics:\n%s", summarizeExpectations(expectations), formatDiagnostics(e.lastDiagnostics)) } } func meetsExpectations(m map[string]*protocol.PublishDiagnosticsParams, expectations []DiagnosticExpectation) bool { for _, e := range expectations { diags, ok := m[e.Path] if !ok { return false } if !e.IsMet(diags) { return false } } return true } // A DiagnosticExpectation is a condition that must be met by the current set // of diagnostics. type DiagnosticExpectation struct { // IsMet determines whether the diagnostics for this file version satisfy our // expectation. IsMet func(*protocol.PublishDiagnosticsParams) bool // Description is a human-readable description of the diagnostic expectation. Description string // Path is the workspace-relative path to the file being asserted on. Path string } // EmptyDiagnostics asserts that diagnostics are empty for the // workspace-relative path name. func EmptyDiagnostics(name string) DiagnosticExpectation { isMet := func(diags *protocol.PublishDiagnosticsParams) bool { return len(diags.Diagnostics) == 0 } return DiagnosticExpectation{ IsMet: isMet, Description: "empty diagnostics", Path: name, } } // AnyDiagnosticAtCurrentVersion asserts that there is a diagnostic report for // the current edited version of the buffer corresponding to the given // workspace-relative pathname. func (e *Env) AnyDiagnosticAtCurrentVersion(name string) DiagnosticExpectation { version := e.E.BufferVersion(name) isMet := func(diags *protocol.PublishDiagnosticsParams) bool { return int(diags.Version) == version } return DiagnosticExpectation{ IsMet: isMet, Description: fmt.Sprintf("any diagnostics at version %d", version), Path: name, } } // DiagnosticAtRegexp expects that there is a diagnostic entry at the start // position matching the regexp search string re in the buffer specified by // name. Note that this currently ignores the end position. func (e *Env) DiagnosticAtRegexp(name, re string) DiagnosticExpectation { pos := e.RegexpSearch(name, re) expectation := DiagnosticAt(name, pos.Line, pos.Column) expectation.Description += fmt.Sprintf(" (location of %q)", re) return expectation } // DiagnosticAt asserts that there is a diagnostic entry at the position // specified by line and col, for the workspace-relative path name. func DiagnosticAt(name string, line, col int) DiagnosticExpectation { isMet := func(diags *protocol.PublishDiagnosticsParams) bool { for _, d := range diags.Diagnostics { if d.Range.Start.Line == float64(line) && d.Range.Start.Character == float64(col) { return true } } return false } return DiagnosticExpectation{ IsMet: isMet, Description: fmt.Sprintf("diagnostic at {line:%d, column:%d}", line, col), Path: name, } } // Await waits for all diagnostic expectations to simultaneously be met. It // should only be called from the main test goroutine. func (e *Env) Await(expectations ...DiagnosticExpectation) { // NOTE: in the future this mechanism extend beyond just diagnostics, for // example by modifying IsMet to be a func(*Env) boo. However, that would // require careful checking of conditions around every state change, so for // now we just limit the scope to diagnostic conditions. e.T.Helper() e.mu.Lock() // Before adding the waiter, we check if the condition is currently met or // failed to avoid a race where the condition was realized before Await was // called. if meetsExpectations(e.lastDiagnostics, expectations) { e.mu.Unlock() return } cond := &diagnosticCondition{ expectations: expectations, met: make(chan struct{}), } e.waiters[e.nextWaiterID] = cond e.nextWaiterID++ e.mu.Unlock() select { case <-e.Ctx.Done(): // Debugging an unmet expectation can be tricky, so we put some effort into // nicely formatting the failure. summary := summarizeExpectations(expectations) e.mu.Lock() diagString := formatDiagnostics(e.lastDiagnostics) e.mu.Unlock() e.T.Fatalf("waiting on:\n\t%s\nerr: %v\ndiagnostics:\n%s", summary, e.Ctx.Err(), diagString) case <-cond.met: } } func summarizeExpectations(expectations []DiagnosticExpectation) string { var descs []string for _, e := range expectations { descs = append(descs, fmt.Sprintf("%s: %s", e.Path, e.Description)) } return strings.Join(descs, "\n\t") } func formatDiagnostics(diags map[string]*protocol.PublishDiagnosticsParams) string { var b strings.Builder for name, params := range diags { b.WriteString(fmt.Sprintf("\t%s (version %d):\n", name, int(params.Version))) for _, d := range params.Diagnostics { b.WriteString(fmt.Sprintf("\t\t(%d, %d): %s\n", int(d.Range.Start.Line), int(d.Range.Start.Character), d.Message)) } } return b.String() }