1
0
mirror of https://github.com/golang/go synced 2024-11-19 00:04:40 -07:00
go/internal/lsp/regtest/env.go
Rob Findley 92fa1ff4b1 internal/lsp/regtest: add support for custom test proxy data
Certain regtests require referencing external data. To support this, add
the ability to use a file-based proxy populated with testdata.

To expose this configuration, augment the regtest runner with variadic
options. Also use this to replace the Runner.RunInMode function.

Add a simple regtest that uses this functionality.

Updates golang/go#36879

Change-Id: I7e6314430abcd127dbb7bca12574ef9935bf1f83
Reviewed-on: https://go-review.googlesource.com/c/tools/+/228235
Run-TryBot: Robert Findley <rfindley@google.com>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Rebecca Stambler <rstambler@golang.org>
2020-04-16 19:38:27 +00:00

546 lines
16 KiB
Go

// 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
// closers is a queue of clean-up functions to run at the end of the entire
// test suite.
closers []io.Closer
}
// 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
}
// AddCloser schedules a closer to be closed at the end of the test run. This
// is useful for Windows in particular, as
func (r *Runner) AddCloser(closer io.Closer) {
r.mu.Lock()
defer r.mu.Unlock()
r.closers = append(r.closers, closer)
}
// Close cleans up resource that have been allocated to this workspace.
func (r *Runner) Close() error {
r.mu.Lock()
defer r.mu.Unlock()
var errmsgs []string
if r.ts != nil {
if err := r.ts.Close(); err != nil {
errmsgs = append(errmsgs, err.Error())
}
}
if r.socketDir != "" {
if err := os.RemoveAll(r.socketDir); err != nil {
errmsgs = append(errmsgs, err.Error())
}
}
for _, closer := range r.closers {
if err := closer.Close(); err != nil {
errmsgs = append(errmsgs, err.Error())
}
}
if len(errmsgs) > 0 {
return fmt.Errorf("errors closing the test runner:\n\t%s", strings.Join(errmsgs, "\n\t"))
}
return nil
}
type runConfig struct {
modes EnvMode
proxyTxt string
timeout time.Duration
}
func (r *Runner) defaultConfig() *runConfig {
return &runConfig{
modes: r.defaultModes,
timeout: r.timeout,
}
}
// A RunOption augments the behavior of the test runner.
type RunOption interface {
set(*runConfig)
}
type optionSetter func(*runConfig)
func (f optionSetter) set(opts *runConfig) {
f(opts)
}
// WithTimeout configures a custom timeout for this test run.
func WithTimeout(d time.Duration) RunOption {
return optionSetter(func(opts *runConfig) {
opts.timeout = d
})
}
// WithProxy configures a file proxy using the given txtar-encoded string.
func WithProxy(txt string) RunOption {
return optionSetter(func(opts *runConfig) {
opts.proxyTxt = txt
})
}
// WithModes configures the execution modes that the test should run in.
func WithModes(modes EnvMode) RunOption {
return optionSetter(func(opts *runConfig) {
opts.modes = modes
})
}
// 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(t *testing.T, e *Env), opts ...RunOption) {
t.Helper()
config := r.defaultConfig()
for _, opt := range opts {
opt.set(config)
}
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 config.modes&tc.mode == 0 {
continue
}
t.Run(tc.name, func(t *testing.T) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), config.timeout)
defer cancel()
ctx = debug.WithInstance(ctx, "", "")
ws, err := fake.NewWorkspace("regtest", filedata, config.proxyTxt)
if err != nil {
t.Fatal(err)
}
// Deferring the closure of ws until the end of the entire test suite
// has, in testing, given the LSP server time to properly shutdown and
// release any file locks held in workspace, which is a problem on
// Windows. This may still be flaky however, and in the future we need a
// better solution to ensure that all Go processes started by gopls have
// exited before we clean up.
r.AddCloser(ws)
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(t, 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()
}