Skip to content

Commit

Permalink
Merge pull request #398 from src-d/repl-integration-testing
Browse files Browse the repository at this point in the history
Repl integration testing
  • Loading branch information
se7entyse7en authored Apr 8, 2019
2 parents a7517b8 + 791ee07 commit d12e19a
Show file tree
Hide file tree
Showing 39 changed files with 1,154 additions and 46 deletions.
95 changes: 95 additions & 0 deletions cmdtests/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ func (s *IntegrationSuite) SetupTest() {
s.Require().NoError(r.Error, r.Combined())
}

func (s *IntegrationSuite) Bin() string {
return srcdBin
}

func (s *IntegrationSuite) RunCmd(cmd string, args []string, cmdOperators ...icmd.CmdOp) *icmd.Result {
args = append([]string{cmd}, args...)
return icmd.RunCmd(icmd.Command(srcdBin, args...), cmdOperators...)
Expand Down Expand Up @@ -120,3 +124,94 @@ func (s *IntegrationTmpDirSuite) SetupTest() {
func (s *IntegrationTmpDirSuite) TearDownTest() {
os.RemoveAll(s.TestDir)
}

type ChannelWriter struct {
ch chan string
}

func NewChannelWriter(ch chan string) *ChannelWriter {
return &ChannelWriter{ch: ch}
}

func (cr *ChannelWriter) Write(b []byte) (int, error) {
cr.ch <- string(b)
return len(b), nil
}

var newLineFormatter = regexp.MustCompile(`(\r\n|\r|\n)`)

func normalizeNewLine(s string) string {
return newLineFormatter.ReplaceAllString(s, "\n")
}

// StreamLinifier is useful when we have a stream of messages, where each message
// can contain multiple lines, and we want to transform it into a stream of messages,
// where each message is a single line.
// Example:
// - input: "foo", "bar\nbaz", "qux\nquux\n"
// - output: "foo", "bar", "baz", "qux", "quux"
//
// This transformation is done through the `Linify` method that reads the input from
// the channel passed as argument and writes the output into the returned channel.
//
// Corner case:
// given the input message "foo\nbar\baz", the lines "foo" and "bar" are written to
// the output channel ASAP, but notice that it's not possible to do the same for
// "baz" which is then marked as *pending*.
// That's because it doesn't end with a new line. In fact, two cases may hold with
// the following message:
// 1. the following message starts with a new line, let's say "\nqux\n",
// 2. the following message doesn't start with a new line, let'say "qux\n".
//
// In the first case, "baz" can be written to the output channel, but in the second
// case, "qux" is the continuation of the same line of "baz", so "bazqux" is the
// message to be written.
// To avoid losing to write the last line, if there's a pending line and and
// an amount of time equal to `newLineTimeout` elapses, then we consider it
// as a completed line and we write the message to the output channel.
type StreamLinifier struct {
newLineTimeout time.Duration
pending string
}

// NewStreamLinifier returns a `StreamLinifier` configure with a given timeout
func NewStreamLinifier(timeout time.Duration) *StreamLinifier {
return &StreamLinifier{newLineTimeout: timeout}
}

// Linify returns a channel to read lines from.
// Messages coming from `in` containing multiple newlines (`(\r\n|\r|\n)`), will
// be sent to the returned channel as multiple messages, one per line.
func (sl *StreamLinifier) Linify(in chan string) chan string {
out := make(chan string)

go func() {
for {
select {
case <-time.After(sl.newLineTimeout):
if sl.pending != "" {
out <- sl.pending
sl.pending = ""
}
case s, ok := <-in:
if !ok {
close(out)
return
}

lines := strings.Split(sl.pending+normalizeNewLine(s), "\n")
sl.pending = ""

for i, l := range lines {
if i == len(lines) && l != "" {
sl.pending = l
break
}
out <- l
}
}
}
}()

return out
}
228 changes: 182 additions & 46 deletions cmdtests/sql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,28 @@
package cmdtests_test

import (
"fmt"
"io"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"runtime"
"strings"
"testing"
"time"

"github.com/kr/pty"
"github.com/src-d/engine/cmdtests"
"github.com/src-d/engine/components"
"github.com/src-d/engine/docker"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"gotest.tools/icmd"
)

type SQLTestSuite struct {
cmdtests.IntegrationTmpDirSuite
}

func TestSQLTestSuite(t *testing.T) {
s := SQLTestSuite{}
suite.Run(t, &s)
}

var showTablesOutput = sqlOutput(`+--------------+
| Table |
+--------------+
Expand All @@ -44,6 +42,181 @@ var showTablesOutput = sqlOutput(`+--------------+
+--------------+
`)

var showRepoTableDescOutput = sqlOutput(`+---------------+------+
| name | type |
+---------------+------+
| repository_id | TEXT |
+---------------+------+
`)

type SQLREPLTestSuite struct {
cmdtests.IntegrationTmpDirSuite
testDir string
}

func TestSQLREPLTestSuite(t *testing.T) {
s := SQLREPLTestSuite{}
suite.Run(t, &s)
}

func (s *SQLREPLTestSuite) TestREPL() {
// When it is not in a terminal, the command reads stdin and exits.
// You can see it running:
// $ echo "show tables;" | ./srcd sql
// So this test does not really interacts with the REPL prompt, but we still
// test that the code that processes each read line is working as expected.

require := s.Require()

input := "show tables;\n" + "describe table repositories;\n"
r := s.RunCmd("sql", nil, icmd.WithStdin(strings.NewReader(input)))
require.NoError(r.Error, r.Combined())
require.Contains(r.Combined(), showTablesOutput)
require.Contains(r.Combined(), showRepoTableDescOutput)
}

func (s *SQLREPLTestSuite) TestInteractiveREPL() {
if runtime.GOOS == "windows" {
s.T().Skip("Testing interactive REPL on Windows is not supported")
}

require := s.Require()

command, in, out, err := s.runInteractiveRepl()
require.NoError(err)

res := s.runInteractiveQuery(in, "show tables;\n", out)
require.Contains(res, showTablesOutput)

res = s.runInteractiveQuery(in, "describe table repositories;\n", out)
require.Contains(res, showRepoTableDescOutput)

require.NoError(s.exitInteractiveAndWait(10*time.Second, in, out))
require.NoError(s.waitMysqlCliContainerStopped(10, 1*time.Second))

command.Wait()
}

func (s *SQLREPLTestSuite) runInteractiveRepl() (*exec.Cmd, io.Writer, <-chan string, error) {
s.T().Helper()

// cannot use `icmd` here, please see: https://github.com/gotestyourself/gotest.tools/issues/151
command := exec.Command(s.Bin(), "sql")

ch := make(chan string)
cr := cmdtests.NewChannelWriter(ch)

command.Stdout = cr
command.Stderr = cr

in, err := pty.Start(command)
if err != nil {
panic(err)
}

linifier := cmdtests.NewStreamLinifier(1 * time.Second)
out := linifier.Linify(ch)
for s := range out {
if strings.HasPrefix(s, "mysql>") {
return command, in, out, nil
}
}

return nil, nil, nil, fmt.Errorf("Mysql cli prompt never started")
}

func (s *SQLREPLTestSuite) runInteractiveQuery(in io.Writer, query string, out <-chan string) string {
io.WriteString(in, query)

var res strings.Builder
for c := range out {
if strings.HasPrefix(c, "Empty set") {
return ""
}

res.WriteString(c + "\r\n")
if s.containsSQLOutput(res.String()) {
break
}
}

return res.String()
}

func (s *SQLREPLTestSuite) exitInteractiveAndWait(timeout time.Duration, in io.Writer, out <-chan string) error {
io.WriteString(in, "exit;\n")

done := make(chan struct{})
go func() {
for c := range out {
if strings.Contains(c, "Bye") {
done <- struct{}{}
// don't return in order to consume all output and let the process exit
}
}
}()

select {
case <-done:
return nil
case <-time.After(timeout):
return fmt.Errorf("timeout of %v elapsed while waiting to exit", timeout)
}
}

func (s *SQLREPLTestSuite) waitMysqlCliContainerStopped(retries int, retryTimeout time.Duration) error {
for i := 0; i < retries; i++ {
running, err := docker.IsRunning(components.MysqlCli.Name, "")
if !running {
return nil
}

if err != nil {
return err
}

time.Sleep(retryTimeout)
}

return fmt.Errorf("maximum number of retries (%d) reached while waiting to stop container", retries)
}

// containsSQLOutput returns `true` if the given string is a SQL output table.
// To detect whether the `out` is a SQL output table, this checks that there
// are exactly 3 separators matching this regex ``\+-+\+`.
// In fact an example of SQL output is the following:
//
// +--------------+ <-- first separator
// | Table |
// +--------------+ <-- second separator
// | blobs |
// | commit_blobs |
// | commit_files |
// | commit_trees |
// | commits |
// | files |
// | ref_commits |
// | refs |
// | remotes |
// | repositories |
// | tree_entries |
// +--------------+ <-- third separator
//
func (s *SQLREPLTestSuite) containsSQLOutput(out string) bool {
sep := regexp.MustCompile(`\+-+\+`)
matches := sep.FindAllStringIndex(out, -1)
return len(matches) == 3
}

type SQLTestSuite struct {
cmdtests.IntegrationTmpDirSuite
}

func TestSQLTestSuite(t *testing.T) {
s := SQLTestSuite{}
suite.Run(t, &s)
}

func (s *SQLTestSuite) TestInit() {
require := s.Require()

Expand Down Expand Up @@ -146,43 +319,6 @@ func (s *SQLTestSuite) TestWrongQuery() {
}
}

func (s *SQLTestSuite) TestREPL() {
// When it is not in a terminal, the command reads stdin and exits.
// You can see it running:
// $ echo "show tables;" | ./srcd sql
// So this test does not really interacts with the REPL prompt, but we still
// test that the code that processes each read line is working as expected.

require := s.Require()

input := "show tables;\n" + "describe table repositories;\n"
r := s.RunCmd("sql", nil, icmd.WithStdin(strings.NewReader(input)))
require.NoError(r.Error, r.Combined())

expected := sqlOutput(`+--------------+
| Table |
+--------------+
| blobs |
| commit_blobs |
| commit_files |
| commit_trees |
| commits |
| files |
| ref_commits |
| refs |
| remotes |
| repositories |
| tree_entries |
+--------------+
+---------------+------+
| name | type |
+---------------+------+
| repository_id | TEXT |
+---------------+------+`)

require.Contains(r.Stdout(), expected)
}

func (s *SQLTestSuite) TestIndexesWorkdirChange() {
require := s.Require()

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ require (
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/jessevdk/go-flags v1.4.0
github.com/kr/pretty v0.1.0 // indirect
github.com/kr/pty v1.1.4
github.com/mcuadros/go-lookup v0.0.0-20171110082742-5650f26be767 // indirect
github.com/mitchellh/go-homedir v1.1.0
github.com/opencontainers/go-digest v1.0.0-rc1 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.4 h1:5Myjjh3JY/NaAi4IsUbHADytDyl1VE1Y9PXDlL+P/VQ=
github.com/kr/pty v1.1.4/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=
Expand Down
4 changes: 4 additions & 0 deletions vendor/github.com/kr/pty/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit d12e19a

Please sign in to comment.