Skip to content
This repository was archived by the owner on Jan 31, 2025. It is now read-only.

Commit 0847953

Browse files
authored
test: add backbone testing with testscripts (#11)
* test: add backbone testing with testscripts This change is a port from the work I did in `gnols` (ancestor of this repo) to have a proper testing framework. It uses testscripts to setup a comprehensive and extensible way to test the gnopls server. The setup consists of running the server and mimicking a LSP client via the "lsp" command used in the txtar files. Each txtar file is used to assert a specific command or functionnality: - testdata/initialize.txtar asserts the lsp initialize command - testdata/not_initialize.txtar asserts that lsp initialize must be called prior to any other commands (for this I had to update the server code accordingly) - testdata/document_hover.txtar asserts the lsp document/hover command - testdata/document_open.txtar assets the lsp document/open command This is only the beginning as I have introduced many other tests in gnols but pushing all scripts requires some other server changes and I'd rather do that incrementally. * fix: upgrade go version Because some error messages has changed between 1.22 and 1.23 and the one asserted in the txtar files are from 1.23 * attempt to know the gnopls working dir in CI * more traces to understand CI failure * fix(CI): install gno before running test Also remove traces added for debuggingj * fix go version in release gh action
1 parent 5a36601 commit 0847953

File tree

11 files changed

+447
-21
lines changed

11 files changed

+447
-21
lines changed

.github/workflows/go.yml

+3-3
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ jobs:
1616
- name: Set up Go
1717
uses: actions/setup-go@v4
1818
with:
19-
go-version: 1.21
19+
go-version-file: go.mod
2020

2121
- name: Build
22-
run: go build -v ./...
22+
run: make build
2323

2424
- name: Test
25-
run: go test -v ./...
25+
run: make test

.github/workflows/releaser.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818

1919
- uses: actions/setup-go@v5
2020
with:
21-
go-version: "1.21.x"
21+
go-version-file: go.mod
2222
cache: true
2323

2424
- uses: goreleaser/goreleaser-action@v6

Makefile

+10-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ PROJECT_NAME = gnopls
33
BUILD_FLAGS = -mod=readonly -ldflags='$(LD_FLAGS)'
44
BUILD_FOLDER = ./build
55

6-
.PHONY: install build clean gen
6+
.PHONY: install build clean gen test deps
77

88
## install: Install the binary.
99
install:
@@ -26,3 +26,12 @@ clean:
2626
## gen: runs "go:generate" across all Go files
2727
gen:
2828
@find . -name '*.go' -print0 | xargs -0 grep -l '//go:generate' | xargs -I {} go generate {}
29+
30+
test: deps
31+
@echo Testing $(PROJECT_NAME)...
32+
@go test -v ./...
33+
34+
deps:
35+
@echo Installing Gno dependency...
36+
@go install github.com/gnolang/gno/gnovm/cmd/gno@master
37+

go.mod

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
module github.com/gnolang/gnopls
22

3-
go 1.22.2
3+
go 1.23
44

55
require (
66
github.com/dave/jennifer v1.7.0
77
github.com/gnolang/gno v0.0.0-20240118150545-7aa81d138701
88
github.com/gnolang/tlin v1.0.1-0.20240930090350-be21dd15c7aa
99
github.com/google/go-github v17.0.0+incompatible
1010
github.com/orcaman/concurrent-map/v2 v2.0.1
11+
github.com/rogpeppe/go-internal v1.11.0
1112
github.com/spf13/cobra v1.5.0
1213
go.lsp.dev/jsonrpc2 v0.10.0
1314
go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2

internal/lsp/error.go

+7-1
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,16 @@ package lsp
33
import (
44
"context"
55
"fmt"
6+
"log/slog"
67

78
"go.lsp.dev/jsonrpc2"
89
)
910

1011
func sendParseError(ctx context.Context, reply jsonrpc2.Replier, err error) error {
11-
return reply(ctx, nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err))
12+
return replyErr(ctx, reply, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err))
13+
}
14+
15+
func replyErr(ctx context.Context, reply jsonrpc2.Replier, err error) error {
16+
slog.Error(err.Error())
17+
return reply(ctx, nil, err)
1218
}

internal/lsp/server.go

+25-14
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"log/slog"
77
"os"
88
"path/filepath"
9+
"sync/atomic"
910

1011
"go.lsp.dev/jsonrpc2"
1112
"go.lsp.dev/protocol"
@@ -23,7 +24,8 @@ type server struct {
2324
completionStore *CompletionStore
2425
cache *Cache
2526

26-
formatOpt tools.FormattingOption
27+
formatOpt tools.FormattingOption
28+
initialized atomic.Bool
2729
}
2830

2931
func BuildServerHandler(conn jsonrpc2.Conn, e *env.Env) jsonrpc2.Handler {
@@ -48,30 +50,39 @@ func BuildServerHandler(conn jsonrpc2.Conn, e *env.Env) jsonrpc2.Handler {
4850
}
4951

5052
func (s *server) ServerHandler(ctx context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error {
53+
if req.Method() == protocol.MethodInitialize {
54+
err := s.Initialize(ctx, reply, req)
55+
if err != nil {
56+
return err
57+
}
58+
s.initialized.Store(true)
59+
return nil
60+
}
61+
if !s.initialized.Load() {
62+
return replyErr(ctx, reply, jsonrpc2.NewError(jsonrpc2.ServerNotInitialized, "server not initialized"))
63+
}
5164
switch req.Method() {
52-
case "exit":
65+
case protocol.MethodExit:
5366
return s.Exit(ctx, reply, req)
54-
case "initialize":
55-
return s.Initialize(ctx, reply, req)
56-
case "initialized":
67+
case protocol.MethodInitialized:
5768
return s.Initialized(ctx, reply, req)
58-
case "shutdown":
69+
case protocol.MethodShutdown:
5970
return s.Shutdown(ctx, reply, req)
60-
case "textDocument/didChange":
71+
case protocol.MethodTextDocumentDidChange:
6172
return s.DidChange(ctx, reply, req)
62-
case "textDocument/didClose":
73+
case protocol.MethodTextDocumentDidClose:
6374
return s.DidClose(ctx, reply, req)
64-
case "textDocument/didOpen":
75+
case protocol.MethodTextDocumentDidOpen:
6576
return s.DidOpen(ctx, reply, req)
66-
case "textDocument/didSave":
77+
case protocol.MethodTextDocumentDidSave:
6778
return s.DidSave(ctx, reply, req)
68-
case "textDocument/formatting":
79+
case protocol.MethodTextDocumentFormatting:
6980
return s.Formatting(ctx, reply, req)
70-
case "textDocument/hover":
81+
case protocol.MethodTextDocumentHover:
7182
return s.Hover(ctx, reply, req)
72-
case "textDocument/completion":
83+
case protocol.MethodTextDocumentCompletion:
7384
return s.Completion(ctx, reply, req)
74-
case "textDocument/definition":
85+
case protocol.MethodTextDocumentDefinition:
7586
return s.Definition(ctx, reply, req)
7687
default:
7788
return jsonrpc2.MethodNotFoundHandler(ctx, reply, req)

main_test.go

+174
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
"sync/atomic"
13+
"testing"
14+
15+
"github.com/rogpeppe/go-internal/testscript"
16+
"go.lsp.dev/jsonrpc2"
17+
18+
lspenv "github.com/gnolang/gnopls/internal/env"
19+
"github.com/gnolang/gnopls/internal/lsp"
20+
"github.com/gnolang/gnopls/internal/version"
21+
)
22+
23+
type buffer struct {
24+
*io.PipeWriter
25+
*io.PipeReader
26+
}
27+
28+
func (b buffer) Close() error {
29+
b.PipeReader.Close()
30+
b.PipeWriter.Close()
31+
return nil
32+
}
33+
34+
// TestScripts runs a gnopls server instance and uses the buffer type to
35+
// communicate with it. Then it executes all the txtar files found in the
36+
// testdata directory.
37+
func TestScripts(t *testing.T) {
38+
testscript.Run(t, testscript.Params{
39+
UpdateScripts: os.Getenv("TXTAR_UPDATE") != "",
40+
Setup: func(env *testscript.Env) error {
41+
var (
42+
clientRead, serverWrite = io.Pipe()
43+
serverRead, clientWrite = io.Pipe()
44+
serverBuf = buffer{
45+
PipeWriter: serverWrite,
46+
PipeReader: serverRead,
47+
}
48+
clientBuf = buffer{
49+
PipeWriter: clientWrite,
50+
PipeReader: clientRead,
51+
}
52+
serverConn = jsonrpc2.NewConn(jsonrpc2.NewStream(serverBuf))
53+
procEnv = &lspenv.Env{
54+
GNOROOT: os.Getenv("GNOROOT"),
55+
GNOHOME: lspenv.GnoHome(),
56+
}
57+
serverHandler = jsonrpc2.HandlerServer(lsp.BuildServerHandler(serverConn, procEnv))
58+
clientConn = jsonrpc2.NewConn(jsonrpc2.NewStream(clientBuf))
59+
)
60+
env.Values["conn"] = clientConn
61+
62+
// Start LSP server
63+
ctx := context.Background()
64+
go func() {
65+
if err := serverHandler.ServeStream(ctx, serverConn); !errors.Is(err, io.ErrClosedPipe) {
66+
env.T().Fatal("Server error", err)
67+
}
68+
}()
69+
// Listen to server notifications
70+
// All servers notifications are expected to be find in the txtar working
71+
// directory, using the following format:
72+
// $WORK/output/notify{i}.json
73+
// where {i} is a counter starting at 1.
74+
var notifyNum atomic.Uint32
75+
clientConn.Go(ctx, func(ctx context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error {
76+
filename := fmt.Sprintf("notify%d.json", notifyNum.Add(1))
77+
return writeJSON(env, filename, req)
78+
})
79+
80+
// Stop LSP server at the end of test
81+
env.Defer(func() {
82+
clientConn.Close()
83+
serverConn.Close()
84+
<-clientConn.Done()
85+
<-serverConn.Done()
86+
})
87+
88+
// GNOPLS_WD is used in txtar files to know the location of the gnopls
89+
// working dir.
90+
env.Setenv("GNOPLS_WD", filepath.Join(lspenv.GnoHome(), "gnopls", "tmp"))
91+
// GNOPLS_VERSION is used in txtar files to know the server version.
92+
// This required because it changes over git status (can be "local" or
93+
// "v0.1.0-local"...)
94+
env.Setenv("GNOPLS_VERSION", version.GetVersion(ctx))
95+
96+
return nil
97+
},
98+
Dir: "testdata",
99+
Cmds: map[string]func(*testscript.TestScript, bool, []string){
100+
// "lsp" is a txtar command that sends a lsp command to the server with
101+
// the following arguments:
102+
// - the method name
103+
// - the path to the file that contains the method parameters
104+
//
105+
// The server's response is encoded into the $WORK/output directory, with
106+
// filename equals to the parameter filename.
107+
// For example, the response of the following command:
108+
// lsp initialized input/initialized.json
109+
// will be available in $WORK/output/initialized.json
110+
"lsp": func(ts *testscript.TestScript, neg bool, args []string) { //nolint:unparam
111+
if len(args) != 2 {
112+
ts.Fatalf("usage: lsp <method> <param_file>")
113+
}
114+
var (
115+
method = args[0]
116+
paramsFile = args[1]
117+
)
118+
call(ts, method, paramsFile)
119+
},
120+
},
121+
})
122+
}
123+
124+
// call decodes paramFile and send it to the server using method.
125+
func call(ts *testscript.TestScript, method string, paramFile string) {
126+
paramStr := ts.ReadFile(paramFile)
127+
// Replace $WORK with real path
128+
paramStr = os.Expand(paramStr, func(key string) string {
129+
if strings.HasPrefix(key, "FILE_") {
130+
// ${FILE_filename} is a convenient directive in txtar files used to be
131+
// replaced by the content of the file "filename".
132+
// Since the prefix is "FILE_", key[5:] holds the filename.
133+
fileContent := ts.ReadFile(key[5:])
134+
// Escape fileContent for JSON format
135+
bz, err := json.Marshal(fileContent)
136+
if err != nil {
137+
ts.Fatalf("encode key %s %q: %v", key, fileContent, err)
138+
}
139+
return string(bz[1 : len(bz)-1]) // remove quote wrapping
140+
}
141+
return ts.Getenv(key)
142+
})
143+
var params any
144+
if err := json.Unmarshal([]byte(paramStr), &params); err != nil {
145+
ts.Fatalf("decode param file %s: %v", paramFile, err)
146+
}
147+
var (
148+
conn = ts.Value("conn").(jsonrpc2.Conn) //nolint:errcheck
149+
response any
150+
)
151+
_, err := conn.Call(context.Background(), method, params, &response)
152+
if err != nil {
153+
response = map[string]any{"error": err}
154+
}
155+
if err := writeJSON(ts, filepath.Base(paramFile), response); err != nil {
156+
ts.Fatalf("writeJSON: %v", err)
157+
}
158+
}
159+
160+
// writeJSON writes x to $WORK/output/filename
161+
func writeJSON(ts interface{ Getenv(string) string }, filename string, x any) error {
162+
workDir := ts.Getenv("WORK")
163+
filename = filepath.Join(workDir, "output", filename)
164+
err := os.MkdirAll(filepath.Dir(filename), os.ModePerm)
165+
if err != nil {
166+
return err
167+
}
168+
bz, err := json.MarshalIndent(x, "", " ")
169+
if err != nil {
170+
return err
171+
}
172+
bz = append(bz, '\n') // txtar files always have a final newline
173+
return os.WriteFile(filename, bz, os.ModePerm)
174+
}

testdata/document_hover.txtar

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Init phase
2+
lsp initialize input/initialize.json
3+
lsp initialized input/initialized.json
4+
lsp textDocument/didOpen input/didOpen_x.json
5+
6+
lsp textDocument/hover input/hover.json
7+
cmp output/hover.json expected/hover.json
8+
-- x.gno --
9+
package foo
10+
11+
func Hello() {}
12+
-- input/initialize.json --
13+
{
14+
"rootUri": "file://$WORK"
15+
}
16+
-- input/initialized.json --
17+
{}
18+
-- input/didOpen_x.json --
19+
{
20+
"textDocument": {
21+
"uri":"file://$WORK/x.gno",
22+
"text":"${FILE_x.gno}"
23+
}
24+
}
25+
-- input/hover.json --
26+
{
27+
"textDocument": {
28+
"uri":"file://$WORK/x.gno"
29+
},
30+
"position": {
31+
"line": 2,
32+
"character": 7
33+
}
34+
}
35+
-- expected/hover.json --
36+
{
37+
"contents": {
38+
"kind": "markdown",
39+
"value": "```gno\nfunc Hello()\n```\n\n"
40+
},
41+
"range": {
42+
"end": {
43+
"character": 23,
44+
"line": 1
45+
},
46+
"start": {
47+
"character": 18,
48+
"line": 1
49+
}
50+
}
51+
}

0 commit comments

Comments
 (0)