Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Repl integration testing #398

Merged
merged 2 commits into from
Apr 8, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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