internal/lsp/lsprpc: add an LSP forwarder and regtest environment

Add a new Forwarder type to the lsprpc package, which implements the
jsonrpc2.StreamServer interface. This will be used to establish some
parity in the implementation of shared and singleton gopls servers.

Much more testing is needed, as is handling for the many edge cases
around forwarding the LSP, but since this is functionally equivalent to
TCP forwarding (and the -remote flag was already broken), I went ahead
and used the Forwarder to replace the forward method in the serve
command. This means that we can now use the combination of -listen and
-remote to chain together gopls servers... not that there's any reason
to do this.

Also, wrap the new regression tests with a focus on expressiveness when
testing the happy path, as well as parameterizing them so that they can
be run against different client/server execution environments. This
started to be sizable enough to warrant moving them to a separate
regtest package. The lsprpc package tests will instead focus on unit
testing the client-server binding logic.

Updates golang/go#36879
Updates golang/go#34111

Change-Id: Ib98131a58aabc69299845d2ecefceccfc1199574
Reviewed-on: https://go-review.googlesource.com/c/tools/+/218698
Run-TryBot: Robert Findley <rfindley@google.com>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Heschi Kreinick <heschi@google.com>
This commit is contained in:
Rob Findley 2020-02-06 19:50:37 -05:00 committed by Robert Findley
parent 98b3097d01
commit 0fd2d649e6
10 changed files with 651 additions and 440 deletions

View File

@ -8,8 +8,6 @@ import (
"context"
"flag"
"fmt"
"io"
"net"
"os"
"golang.org/x/tools/internal/jsonrpc2"
@ -64,11 +62,13 @@ func (s *Serve) Run(ctx context.Context, args ...string) error {
s.app.debug.Serve(ctx)
s.app.debug.MonitorMemory(ctx)
var ss jsonrpc2.StreamServer
if s.app.Remote != "" {
return s.forward()
ss = lsprpc.NewForwarder(s.app.Remote, true)
} else {
ss = lsprpc.NewStreamServer(cache.New(s.app.options), true)
}
ss := lsprpc.NewStreamServer(cache.New(s.app.options), true)
if s.Address != "" {
return jsonrpc2.ListenAndServe(ctx, s.Address, ss)
}
@ -82,23 +82,3 @@ func (s *Serve) Run(ctx context.Context, args ...string) error {
}
return ss.ServeStream(ctx, stream)
}
func (s *Serve) forward() error {
conn, err := net.Dial("tcp", s.app.Remote)
if err != nil {
return err
}
errc := make(chan error)
go func(conn net.Conn) {
_, err := io.Copy(conn, os.Stdin)
errc <- err
}(conn)
go func(conn net.Conn) {
_, err := io.Copy(os.Stdout, conn)
errc <- err
}(conn)
return <-errc
}

View File

@ -36,6 +36,16 @@ type Edit struct {
Text string
}
// NewEdit creates an edit replacing all content between
// (startLine, startColumn) and (endLine, endColumn) with text.
func NewEdit(startLine, startColumn, endLine, endColumn int, text string) Edit {
return Edit{
Start: Pos{Line: startLine, Column: startColumn},
End: Pos{Line: endLine, Column: endColumn},
Text: text,
}
}
func (e Edit) toProtocolChangeEvent() protocol.TextDocumentContentChangeEvent {
return protocol.TextDocumentContentChangeEvent{
Range: &protocol.Range{

View File

@ -1,101 +0,0 @@
// 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 lsprpc
import (
"fmt"
"path"
"testing"
"time"
"golang.org/x/tools/internal/lsp/fake"
)
const internalDefinition = `
-- go.mod --
module mod
go 1.12
-- main.go --
package main
import "fmt"
func main() {
fmt.Println(message)
}
-- const.go --
package main
const message = "Hello World."
`
func TestGoToInternalDefinition(t *testing.T) {
t.Parallel()
ctx, env, cleanup := setupEnv(t, internalDefinition)
defer cleanup()
if err := env.editor.OpenFile(ctx, "main.go"); err != nil {
t.Fatal(err)
}
name, pos, err := env.editor.GoToDefinition(ctx, "main.go", fake.Pos{Line: 5, Column: 13})
if err != nil {
t.Fatal(err)
}
if want := "const.go"; name != want {
t.Errorf("GoToDefinition: got file %q, want %q", name, want)
}
if want := (fake.Pos{Line: 2, Column: 6}); pos != want {
t.Errorf("GoToDefinition: got position %v, want %v", pos, want)
}
}
const stdlibDefinition = `
-- go.mod --
module mod
go 1.12
-- main.go --
package main
import (
"fmt"
"time"
)
func main() {
fmt.Println(time.Now())
}`
func TestGoToStdlibDefinition(t *testing.T) {
t.Parallel()
ctx, env, cleanup := setupEnv(t, stdlibDefinition)
defer cleanup()
if err := env.editor.OpenFile(ctx, "main.go"); err != nil {
t.Fatal(err)
}
name, pos, err := env.editor.GoToDefinition(ctx, "main.go", fake.Pos{Line: 8, Column: 19})
if err != nil {
t.Fatal(err)
}
fmt.Println(time.Now())
if got, want := path.Base(name), "time.go"; got != want {
t.Errorf("GoToDefinition: got file %q, want %q", name, want)
}
// Test that we can jump to definition from outside our workspace.
// See golang.org/issues/37045.
newName, newPos, err := env.editor.GoToDefinition(ctx, name, pos)
if err != nil {
t.Fatal(err)
}
if newName != name {
t.Errorf("GoToDefinition is not idempotent: got %q, want %q", newName, name)
}
if newPos != pos {
t.Errorf("GoToDefinition is not idempotent: got %v, want %v", newPos, pos)
}
}

View File

@ -1,304 +0,0 @@
// 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 lsprpc
import (
"context"
"errors"
"fmt"
"testing"
"time"
"golang.org/x/tools/internal/jsonrpc2/servertest"
"golang.org/x/tools/internal/lsp/cache"
"golang.org/x/tools/internal/lsp/fake"
"golang.org/x/tools/internal/lsp/protocol"
)
const exampleProgram = `
-- go.mod --
module mod
go 1.12
-- main.go --
package main
import "fmt"
func main() {
fmt.Println("Hello World.")
}`
type testEnvironment struct {
editor *fake.Editor
ws *fake.Workspace
ts *servertest.Server
dw diagnosticsWatcher
}
func setupEnv(t *testing.T, txt string) (context.Context, testEnvironment, func()) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
ws, err := fake.NewWorkspace("lsprpc", []byte(txt))
if err != nil {
t.Fatal(err)
}
ss := NewStreamServer(cache.New(nil), false)
ts := servertest.NewServer(ctx, ss)
cc := ts.Connect(ctx)
editor, err := fake.NewConnectedEditor(ctx, ws, cc)
if err != nil {
t.Fatal(err)
}
dw := newDiagWatcher(ws)
editor.Client().OnDiagnostics(dw.onDiagnostics)
cleanup := func() {
cancel()
ts.Close()
ws.Close()
}
return ctx, testEnvironment{
editor: editor,
ws: ws,
ts: ts,
dw: dw,
}, cleanup
}
type diagnosticsWatcher struct {
diagnostics chan *protocol.PublishDiagnosticsParams
ws *fake.Workspace
}
func newDiagWatcher(ws *fake.Workspace) diagnosticsWatcher {
return diagnosticsWatcher{
// Allow an arbitrarily large buffer, as we should never want onDiagnostics
// to block.
diagnostics: make(chan *protocol.PublishDiagnosticsParams, 1000),
ws: ws,
}
}
func (w diagnosticsWatcher) onDiagnostics(_ context.Context, p *protocol.PublishDiagnosticsParams) error {
w.diagnostics <- p
return nil
}
func (w diagnosticsWatcher) await(ctx context.Context, expected ...string) (map[string]*protocol.PublishDiagnosticsParams, error) {
expectedSet := make(map[string]bool)
for _, e := range expected {
expectedSet[e] = true
}
got := make(map[string]*protocol.PublishDiagnosticsParams)
for len(got) < len(expectedSet) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case d := <-w.diagnostics:
pth := w.ws.URIToPath(d.URI)
if expectedSet[pth] {
got[pth] = d
}
}
}
return got, nil
}
func checkDiagnosticLocation(params *protocol.PublishDiagnosticsParams, filename string, line, col int) error {
if got, want := params.URI, filename; got != want {
return fmt.Errorf("got diagnostics for URI %q, want %q", got, want)
}
if len(params.Diagnostics) == 0 {
return errors.New("empty diagnostics")
}
diag := params.Diagnostics[0]
if diag.Range.Start.Line != float64(line) || diag.Range.Start.Character != float64(col) {
return fmt.Errorf("Diagnostics[0].Range.Start = %v, want (5,5)", diag.Range.Start)
}
return nil
}
func TestDiagnosticErrorInEditedFile(t *testing.T) {
t.Parallel()
ctx, env, cleanup := setupEnv(t, exampleProgram)
defer cleanup()
// Deleting the 'n' at the end of Println should generate a single error
// diagnostic.
edits := []fake.Edit{
{
Start: fake.Pos{Line: 5, Column: 11},
End: fake.Pos{Line: 5, Column: 12},
Text: "",
},
}
if err := env.editor.OpenFile(ctx, "main.go"); err != nil {
t.Fatal(err)
}
if err := env.editor.EditBuffer(ctx, "main.go", edits); err != nil {
t.Fatal(err)
}
diags, err := env.dw.await(ctx, "main.go")
if err != nil {
t.Fatal(err)
}
if err := checkDiagnosticLocation(diags["main.go"], env.ws.URI("main.go"), 5, 5); err != nil {
t.Fatal(err)
}
}
func TestSimultaneousEdits(t *testing.T) {
t.Parallel()
ctx, env, cleanup := setupEnv(t, exampleProgram)
defer cleanup()
// Set up a second editor session connected to the same server, using the
// same workspace.
conn2 := env.ts.Connect(ctx)
editor2, err := fake.NewConnectedEditor(ctx, env.ws, conn2)
if err != nil {
t.Fatal(err)
}
dw2 := newDiagWatcher(env.ws)
editor2.Client().OnDiagnostics(dw2.onDiagnostics)
// In editor #1, break fmt.Println as before.
edits1 := []fake.Edit{{
Start: fake.Pos{Line: 5, Column: 11},
End: fake.Pos{Line: 5, Column: 12},
Text: "",
}}
if err := env.editor.OpenFile(ctx, "main.go"); err != nil {
t.Fatal(err)
}
if err := env.editor.EditBuffer(ctx, "main.go", edits1); err != nil {
t.Fatal(err)
}
// In editor #2 remove the closing brace.
edits2 := []fake.Edit{{
Start: fake.Pos{Line: 6, Column: 0},
End: fake.Pos{Line: 6, Column: 1},
Text: "",
}}
if err := editor2.OpenFile(ctx, "main.go"); err != nil {
t.Fatal(err)
}
if err := editor2.EditBuffer(ctx, "main.go", edits2); err != nil {
t.Fatal(err)
}
diags1, err := env.dw.await(ctx, "main.go")
if err != nil {
t.Fatal(err)
}
diags2, err := dw2.await(ctx, "main.go")
if err != nil {
t.Fatal(err)
}
if err := checkDiagnosticLocation(diags1["main.go"], env.ws.URI("main.go"), 5, 5); err != nil {
t.Fatal(err)
}
if err := checkDiagnosticLocation(diags2["main.go"], env.ws.URI("main.go"), 7, 0); err != nil {
t.Fatal(err)
}
}
const brokenFile = `package main
const Foo = "abc
`
func TestDiagnosticErrorInNewFile(t *testing.T) {
t.Parallel()
ctx, env, cleanup := setupEnv(t, exampleProgram)
defer cleanup()
if err := env.editor.CreateBuffer(ctx, "broken.go", brokenFile); err != nil {
t.Fatal(err)
}
_, err := env.dw.await(ctx, "broken.go")
if err != nil {
t.Fatal(err)
}
}
// badPackage contains a duplicate definition of the 'a' const.
const badPackage = `
-- go.mod --
module mod
go 1.12
-- a.go --
package consts
const a = 1
-- b.go --
package consts
const a = 2
`
func TestDiagnosticClearingOnEdit(t *testing.T) {
t.Parallel()
ctx, env, cleanup := setupEnv(t, badPackage)
defer cleanup()
if err := env.editor.OpenFile(ctx, "b.go"); err != nil {
t.Fatal(err)
}
_, err := env.dw.await(ctx, "a.go", "b.go")
if err != nil {
t.Fatal(err)
}
// In editor #2 remove the closing brace.
edits := []fake.Edit{{
Start: fake.Pos{Line: 2, Column: 6},
End: fake.Pos{Line: 2, Column: 7},
Text: "b",
}}
if err := env.editor.EditBuffer(ctx, "b.go", edits); err != nil {
t.Fatal(err)
}
diags, err := env.dw.await(ctx, "a.go", "b.go")
if err != nil {
t.Fatal(err)
}
for pth, d := range diags {
if len(d.Diagnostics) != 0 {
t.Errorf("non-empty diagnostics for %q", pth)
}
}
}
func TestDiagnosticClearingOnDelete(t *testing.T) {
t.Skip("skipping due to golang.org/issues/37049")
t.Parallel()
ctx, env, cleanup := setupEnv(t, badPackage)
defer cleanup()
if err := env.editor.OpenFile(ctx, "a.go"); err != nil {
t.Fatal(err)
}
_, err := env.dw.await(ctx, "a.go", "b.go")
if err != nil {
t.Fatal(err)
}
env.ws.RemoveFile(ctx, "b.go")
// TODO(golang.org/issues/37049): here we only get diagnostics for a.go.
diags, err := env.dw.await(ctx, "a.go", "b.go")
if err != nil {
t.Fatal(err)
}
for pth, d := range diags {
if len(d.Diagnostics) != 0 {
t.Errorf("non-empty diagnostics for %q", pth)
}
}
}

View File

@ -8,7 +8,10 @@ package lsprpc
import (
"context"
"fmt"
"net"
"golang.org/x/sync/errgroup"
"golang.org/x/tools/internal/jsonrpc2"
"golang.org/x/tools/internal/lsp"
"golang.org/x/tools/internal/lsp/protocol"
@ -51,3 +54,55 @@ func (s *StreamServer) ServeStream(ctx context.Context, stream jsonrpc2.Stream)
}
return conn.Run(protocol.WithClient(ctx, client))
}
// A Forwarder is a jsonrpc2.StreamServer that handles an LSP stream by
// forwarding it to a remote. This is used when the gopls process started by
// the editor is in the `-remote` mode, which means it finds and connects to a
// separate gopls daemon. In these cases, we still want the forwarder gopls to
// be instrumented with telemetry, and want to be able to in some cases hijack
// the jsonrpc2 connection with the daemon.
type Forwarder struct {
remote string
withTelemetry bool
}
// NewForwarder creates a new Forwarder, ready to forward connections to the
// given remote.
func NewForwarder(remote string, withTelemetry bool) *Forwarder {
return &Forwarder{
remote: remote,
withTelemetry: withTelemetry,
}
}
// ServeStream dials the forwarder remote and binds the remote to serve the LSP
// on the incoming stream.
func (f *Forwarder) ServeStream(ctx context.Context, stream jsonrpc2.Stream) error {
clientConn := jsonrpc2.NewConn(stream)
client := protocol.ClientDispatcher(clientConn)
netConn, err := net.Dial("tcp", f.remote)
if err != nil {
return fmt.Errorf("forwarder: dialing remote: %v", err)
}
serverConn := jsonrpc2.NewConn(jsonrpc2.NewHeaderStream(netConn, netConn))
server := protocol.ServerDispatcher(serverConn)
// Forward between connections.
serverConn.AddHandler(protocol.ClientHandler(client))
serverConn.AddHandler(protocol.Canceller{})
clientConn.AddHandler(protocol.ServerHandler(server))
clientConn.AddHandler(protocol.Canceller{})
if f.withTelemetry {
clientConn.AddHandler(telemetryHandler{})
}
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
return serverConn.Run(ctx)
})
g.Go(func() error {
return clientConn.Run(ctx)
})
return g.Wait()
}

View File

@ -102,17 +102,33 @@ func TestRequestCancellation(t *testing.T) {
},
}
ctx := context.Background()
ts := servertest.NewServer(ctx, ss)
cc := ts.Connect(ctx)
cc.AddHandler(protocol.Canceller{})
lensCtx, cancelLens := context.WithCancel(context.Background())
go func() {
protocol.ServerDispatcher(cc).CodeLens(lensCtx, &protocol.CodeLensParams{})
}()
<-server.started
cancelLens()
if got, want := <-server.finished, true; got != want {
t.Errorf("CodeLens was cancelled: %t, want %t", got, want)
tsDirect := servertest.NewServer(ctx, ss)
forwarder := NewForwarder(tsDirect.Addr, false)
tsForwarded := servertest.NewServer(ctx, forwarder)
tests := []struct {
serverType string
ts *servertest.Server
}{
{"direct", tsDirect},
{"forwarder", tsForwarded},
}
for _, test := range tests {
t.Run(test.serverType, func(t *testing.T) {
cc := test.ts.Connect(ctx)
cc.AddHandler(protocol.Canceller{})
lensCtx, cancelLens := context.WithCancel(context.Background())
go func() {
protocol.ServerDispatcher(cc).CodeLens(lensCtx, &protocol.CodeLensParams{})
}()
<-server.started
cancelLens()
if got, want := <-server.finished, true; got != want {
t.Errorf("CodeLens was cancelled: %t, want %t", got, want)
}
})
}
}

View File

@ -0,0 +1,84 @@
// 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
import (
"context"
"path"
"testing"
"golang.org/x/tools/internal/lsp/fake"
)
const internalDefinition = `
-- go.mod --
module mod
go 1.12
-- main.go --
package main
import "fmt"
func main() {
fmt.Println(message)
}
-- const.go --
package main
const message = "Hello World."
`
func TestGoToInternalDefinition(t *testing.T) {
t.Parallel()
runner.Run(t, internalDefinition, func(ctx context.Context, t *testing.T, env *Env) {
env.OpenFile("main.go")
name, pos := env.GoToDefinition("main.go", fake.Pos{Line: 5, Column: 13})
if want := "const.go"; name != want {
t.Errorf("GoToDefinition: got file %q, want %q", name, want)
}
if want := (fake.Pos{Line: 2, Column: 6}); pos != want {
t.Errorf("GoToDefinition: got position %v, want %v", pos, want)
}
})
}
const stdlibDefinition = `
-- go.mod --
module mod
go 1.12
-- main.go --
package main
import (
"fmt"
"time"
)
func main() {
fmt.Println(time.Now())
}`
func TestGoToStdlibDefinition(t *testing.T) {
t.Parallel()
runner.Run(t, stdlibDefinition, func(ctx context.Context, t *testing.T, env *Env) {
env.OpenFile("main.go")
name, pos := env.GoToDefinition("main.go", fake.Pos{Line: 8, Column: 19})
if got, want := path.Base(name), "time.go"; got != want {
t.Errorf("GoToDefinition: got file %q, want %q", name, want)
}
// Test that we can jump to definition from outside our workspace.
// See golang.org/issues/37045.
newName, newPos := env.GoToDefinition(name, pos)
if newName != name {
t.Errorf("GoToDefinition is not idempotent: got %q, want %q", newName, name)
}
if newPos != pos {
t.Errorf("GoToDefinition is not idempotent: got %v, want %v", newPos, pos)
}
})
}

View File

@ -0,0 +1,116 @@
// 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
import (
"context"
"testing"
"golang.org/x/tools/internal/lsp/fake"
)
const exampleProgram = `
-- go.mod --
module mod
go 1.12
-- main.go --
package main
import "fmt"
func main() {
fmt.Println("Hello World.")
}`
func TestDiagnosticErrorInEditedFile(t *testing.T) {
t.Parallel()
runner.Run(t, exampleProgram, func(ctx context.Context, t *testing.T, env *Env) {
// Deleting the 'n' at the end of Println should generate a single error
// diagnostic.
edit := fake.NewEdit(5, 11, 5, 12, "")
env.OpenFile("main.go")
env.EditBuffer("main.go", edit)
env.Await(DiagnosticAt("main.go", 5, 5))
})
}
func TestSimultaneousEdits(t *testing.T) {
t.Parallel()
runner.Run(t, exampleProgram, func(ctx context.Context, t *testing.T, env1 *Env) {
// Create a second test session connected to the same workspace and server
// as the first.
env2 := NewEnv(ctx, t, env1.W, env1.Server)
// In editor #1, break fmt.Println as before.
edit1 := fake.NewEdit(5, 11, 5, 12, "")
env1.OpenFile("main.go")
env1.EditBuffer("main.go", edit1)
// In editor #2 remove the closing brace.
edit2 := fake.NewEdit(6, 0, 6, 1, "")
env2.OpenFile("main.go")
env2.EditBuffer("main.go", edit2)
// Now check that we got different diagnostics in each environment.
env1.Await(DiagnosticAt("main.go", 5, 5))
env2.Await(DiagnosticAt("main.go", 7, 0))
})
}
const brokenFile = `package main
const Foo = "abc
`
func TestDiagnosticErrorInNewFile(t *testing.T) {
t.Parallel()
runner.Run(t, brokenFile, func(ctx context.Context, t *testing.T, env *Env) {
env.CreateBuffer("broken.go", brokenFile)
env.Await(DiagnosticAt("broken.go", 2, 12))
})
}
// badPackage contains a duplicate definition of the 'a' const.
const badPackage = `
-- go.mod --
module mod
go 1.12
-- a.go --
package consts
const a = 1
-- b.go --
package consts
const a = 2
`
func TestDiagnosticClearingOnEdit(t *testing.T) {
t.Parallel()
runner.Run(t, badPackage, func(ctx context.Context, t *testing.T, env *Env) {
env.OpenFile("b.go")
env.Await(DiagnosticAt("a.go", 2, 6), DiagnosticAt("b.go", 2, 6))
// Fix the error by editing the const name in b.go to `b`.
edit := fake.NewEdit(2, 6, 2, 7, "b")
env.EditBuffer("b.go", edit)
env.Await(EmptyDiagnostics("a.go"), EmptyDiagnostics("b.go"))
})
}
func TestDiagnosticClearingOnDelete(t *testing.T) {
t.Skip("skipping due to golang.org/issues/37049")
t.Parallel()
runner.Run(t, badPackage, func(ctx context.Context, t *testing.T, env *Env) {
env.OpenFile("a.go")
env.Await(DiagnosticAt("a.go", 2, 6), DiagnosticAt("b.go", 2, 6))
env.RemoveFileFromWorkspace("b.go")
// TODO(golang.org/issues/37049): here we only get diagnostics for a.go.
env.Await(EmptyDiagnostics("a.go"), EmptyDiagnostics("b.go"))
})
}

336
internal/lsp/regtest/env.go Normal file
View File

@ -0,0 +1,336 @@
// 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 (
"context"
"fmt"
"strings"
"sync"
"testing"
"time"
"golang.org/x/tools/internal/jsonrpc2/servertest"
"golang.org/x/tools/internal/lsp/cache"
"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
// Shared mode uses a Shared cache
Shared
// Forwarded forwards connections
Forwarded
// AllModes runs tests in all modes
AllModes = Singleton | Shared | 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 {
ts *servertest.Server
modes EnvMode
timeout time.Duration
}
// NewTestRunner creates a Runner with its shared state initialized, ready to
// run tests.
func NewTestRunner(modes EnvMode, testTimeout time.Duration) *Runner {
ss := lsprpc.NewStreamServer(cache.New(nil), false)
ts := servertest.NewServer(context.Background(), ss)
return &Runner{
ts: ts,
modes: modes,
timeout: testTimeout,
}
}
// Close cleans up resource that have been allocated to this workspace.
func (r *Runner) Close() error {
return r.ts.Close()
}
// Run executes the test function in in all 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(context.Context, *testing.T, *Env)) {
t.Helper()
tests := []struct {
name string
mode EnvMode
makeServer func(context.Context, *testing.T) (*servertest.Server, func())
}{
{"singleton", Singleton, r.singletonEnv},
{"shared", Shared, r.sharedEnv},
{"forwarded", Forwarded, r.forwardedEnv},
}
for _, tc := range tests {
tc := tc
if r.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()
ws, err := fake.NewWorkspace("lsprpc", []byte(filedata))
if err != nil {
t.Fatal(err)
}
defer ws.Close()
ts, cleanup := tc.makeServer(ctx, t)
defer cleanup()
env := NewEnv(ctx, t, ws, ts)
test(ctx, t, env)
})
}
}
func (r *Runner) singletonEnv(ctx context.Context, t *testing.T) (*servertest.Server, func()) {
ss := lsprpc.NewStreamServer(cache.New(nil), false)
ts := servertest.NewServer(ctx, ss)
cleanup := func() {
ts.Close()
}
return ts, cleanup
}
func (r *Runner) sharedEnv(ctx context.Context, t *testing.T) (*servertest.Server, func()) {
return r.ts, func() {}
}
func (r *Runner) forwardedEnv(ctx context.Context, t *testing.T) (*servertest.Server, func()) {
forwarder := lsprpc.NewForwarder(r.ts.Addr, false)
ts2 := servertest.NewServer(ctx, forwarder)
cleanup := func() {
ts2.Close()
}
return ts2, cleanup
}
// 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 or editor, or server,
// but they are available if needed.
W *fake.Workspace
E *fake.Editor
Server *servertest.Server
// 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.
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.Server) *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,
lastDiagnostics: make(map[string]*protocol.PublishDiagnosticsParams),
waiters: make(map[int]*diagnosticCondition),
}
env.E.Client().OnDiagnostics(env.onDiagnostics)
return env
}
// RemoveFileFromWorkspace deletes a file on disk but does nothing in the
// editor. It calls t.Fatal on any error.
func (e *Env) RemoveFileFromWorkspace(name string) {
e.t.Helper()
if err := e.W.RemoveFile(e.ctx, name); err != nil {
e.t.Fatal(err)
}
}
// OpenFile opens a file in the editor, calling t.Fatal on any error.
func (e *Env) OpenFile(name string) {
e.t.Helper()
if err := e.E.OpenFile(e.ctx, name); err != nil {
e.t.Fatal(err)
}
}
// CreateBuffer creates a buffer in the editor, calling t.Fatal on any error.
func (e *Env) CreateBuffer(name string, content string) {
e.t.Helper()
if err := e.E.CreateBuffer(e.ctx, name, content); err != nil {
e.t.Fatal(err)
}
}
// EditBuffer applies edits to an editor buffer, calling t.Fatal on any error.
func (e *Env) EditBuffer(name string, edits ...fake.Edit) {
e.t.Helper()
if err := e.E.EditBuffer(e.ctx, name, edits); err != nil {
e.t.Fatal(err)
}
}
// GoToDefinition goes to definition in the editor, calling t.Fatal on any
// error.
func (e *Env) GoToDefinition(name string, pos fake.Pos) (string, fake.Pos) {
e.t.Helper()
n, p, err := e.E.GoToDefinition(e.ctx, name, pos)
if err != nil {
e.t.Fatal(err)
}
return n, p
}
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 meetsCondition(e.lastDiagnostics, condition.expectations) {
delete(e.waiters, id)
close(condition.met)
}
}
return nil
}
func meetsCondition(m map[string]*protocol.PublishDiagnosticsParams, expectations []DiagnosticExpectation) bool {
for _, e := range expectations {
if !e.IsMet(m) {
return false
}
}
return true
}
// A DiagnosticExpectation is a condition that must be met by the current set
// of diagnostics.
type DiagnosticExpectation struct {
IsMet func(map[string]*protocol.PublishDiagnosticsParams) bool
Description string
}
// EmptyDiagnostics asserts that diagnostics are empty for the
// workspace-relative path name.
func EmptyDiagnostics(name string) DiagnosticExpectation {
isMet := func(diags map[string]*protocol.PublishDiagnosticsParams) bool {
ds, ok := diags[name]
return ok && len(ds.Diagnostics) == 0
}
return DiagnosticExpectation{
IsMet: isMet,
Description: fmt.Sprintf("empty diagnostics for %q", name),
}
}
// 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 map[string]*protocol.PublishDiagnosticsParams) bool {
ds, ok := diags[name]
if !ok || len(ds.Diagnostics) == 0 {
return false
}
for _, d := range ds.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 in %q at (line:%d, column:%d)", name, line, col),
}
}
// Await waits for all diagnostic expectations to simultaneously be met.
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 to
// avoid a race where the condition was realized before Await was called.
if meetsCondition(e.lastDiagnostics, expectations) {
e.mu.Unlock()
return
}
met := make(chan struct{})
e.waiters[e.nextWaiterID] = &diagnosticCondition{
expectations: expectations,
met: met,
}
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.
var descs []string
for _, e := range expectations {
descs = append(descs, e.Description)
}
e.mu.Lock()
diagString := formatDiagnostics(e.lastDiagnostics)
e.mu.Unlock()
e.t.Fatalf("waiting on (%s):\nerr:%v\ndiagnostics:\n%s", strings.Join(descs, ", "), e.ctx.Err(), diagString)
case <-met:
}
}
func formatDiagnostics(diags map[string]*protocol.PublishDiagnosticsParams) string {
var b strings.Builder
for name, params := range diags {
b.WriteString(name + ":\n")
for _, d := range params.Diagnostics {
b.WriteString(fmt.Sprintf("\t(%d, %d): %s\n", int(d.Range.Start.Line), int(d.Range.Start.Character), d.Message))
}
}
return b.String()
}

View File

@ -0,0 +1,19 @@
// 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
import (
"os"
"testing"
"time"
)
var runner *Runner
func TestMain(m *testing.M) {
runner = NewTestRunner(AllModes, 30*time.Second)
defer runner.Close()
os.Exit(m.Run())
}